# Time Unification Plan

Audit date: 2026-06-12. Companion to `IMPORT_METRICS_FIX_PLAN.md` (import/metrics day-bucketing fixes, implemented 2026-06-11).

> **IMPLEMENTATION STATUS (2026-06-12):** Phases 1–6 code implemented. Shared
> module at `inventory-server/shared/business-time/` (+ frontend mirror
> `inventory/src/utils/businessTime.ts`); both duplicate utils deleted;
> acot-server hour-late bug fixed; `dateStrings: true` on all mysql2 configs
> except the import pipeline (dynamic Chicago offset instead);
> `forecast_engine.py` anchors on `business_today()`; pg pools pin
> `TimeZone=America/Chicago`; `/var/www/ecosystem.config.cjs` has
> `TZ: 'America/Chicago'` (backup at `~/ecosystem.config.cjs.bak.2026-06-12`);
> supervised import+metrics cycle ran clean. Docs at
> `inventory-server/docs/TIME.md`.
>
> **Still pending (Matt):** ① `sudo -u postgres psql -c "ALTER DATABASE
> inventory_db SET timezone = 'America/Chicago';"` ② `pm2 reload
> ecosystem.config.cjs --update-env` (mind the JWT_SECRET shell-var gotcha)
> ③ flush dashboard Redis ④ frontend `npm run build` (deploys) ⑤ Phase 2
> before/after verification + Klaviyo UTC spot-check. Deferred (non-correctness):
> GA4/Meta footnote labels. (UPDATE 2026-06-13: the two-selector-kit UI
> consolidation — `BusinessRangeSelect` + `HorizonTabs` — is now implemented; see Phase 5.)

## The Convention (single source of truth)

**A business day runs 1:00:00am Eastern → 12:59:59am Eastern the next day.**

Because 1am Eastern == midnight Central year-round (both observe US DST), this is identical to:

> **Business date = the America/Chicago calendar date.**

Practical consequences — these are the ONLY approved idioms:


| Context                                                            | Correct idiom                                                                                                                                   |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| PG `timestamptz` → business date                                   | `(ts AT TIME ZONE 'America/Chicago')::date` — or plain `ts::date` / `CURRENT_DATE` once Phase 1 sets the session TZ                             |
| MySQL DATETIME literal (stores Central wall-clock) → business date | `DATE(col)` — **no hour shift**                                                                                                                 |
| MySQL WHERE bounds for a business day                              | Central wall-clock strings: `col >= 'YYYY-MM-DD 00:00:00' AND col < 'YYYY-MM-DD+1 00:00:00'`                                                    |
| Luxon                                                              | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset                                      |
| Intervals                                                          | **Half-open** `[start, nextStart)`. No `-1 minute`, no `-1 ms` endpoints                                                                        |
| "Today" in JS                                                      | a `businessToday()` helper (Chicago date), never `new Date().toISOString().split('T')[0]` (UTC) and never bare `CURRENT_DATE` reasoning in Node |


## Verified facts (2026-06-11/12)

- MySQL (192.168.1.5) session/global TZ = SYSTEM = America/Chicago. `NOW()` = UTC-5 (CDT) ✓ live-checked.
- `_order.date_placed`, `_order.date_shipped` are DATETIME storing **Central wall-clock** ✓ (recent order: `date_placed 18:28` at 23:45 UTC).
- `timeclock.TimeStamp` stores **Central wall-clock** ✓ (8am-ET punch-ins recorded as ~07:03–07:06; `NOW()` = 08:14 at ~9:14am ET).
- `picking_ticket.createddate` — PHP-written, Central (same as all PHP DATETIME writes; spot-check during Phase 2 anyway).
- PostgreSQL `inventory_db` session TZ = **Europe/Berlin** ✓ (`SHOW timezone`).
- Node processes on netcup run with system TZ Europe/Berlin (no `TZ` in `.env`/ecosystem).
- Browsers/office = America/New_York.
- GA4 property reporting TZ = America/New_York (per Matt); API `NdaysAgo` resolves in property TZ.
- Klaviyo account TZ = Eastern; API documented to return UTC — **verify in Phase 5** (one event spot-check).
- PG columns: `orders.date`, `receivings.received_date`, `purchase_orders.date`, `products.created_at/date_online`, `product_metrics.last_calculated` = timestamptz; `stock_snapshots.snapshot_date`, `daily_product_snapshots.snapshot_date`, `purchase_orders.expected_date`, `product_forecasts.forecast_date` = DATE.

## Known defects this plan fixes

1. **acot-server dashboard windows are 1 hour late** (today = 2am–2am ET): `toDatabaseSqlString()` in `dashboard/acot-server/utils/timeUtils.js` passes Eastern wall-clock (via `keepLocalTime: true` + fixed `UTC-05:00`) to a Central-literal DB.
2. **Fixed `UTC-05:00` offsets** in `timeUtils.js` (`DB_TIMEZONE`) and `events.js:639` — wrong every winter (CST = UTC-6).
3. `**DATE_SUB(col, INTERVAL 1 HOUR)` daily bucketing** in operations/employee metrics — assumed Eastern storage; columns are Central, so buckets are also 1h late.
4. **payroll-metrics punch parsing**: mysql2 `timezone: 'Z'` turns `07:58` Central into `07:58Z`, then luxon renders it as 03:58 ET — punches shifted −5h before pay-period math; overnight punches (midnight–5am CT) land on the wrong day.
5. **PG routes use Berlin "today"**: every `CURRENT_DATE` / `NOW()` / `::date` / `DATE(col)` in `src/routes/`* and the window anchors in `scripts/metrics-new/*.sql` flip to the next business day at 6pm ET.
6. **JS UTC "today"** (`new Date().toISOString().split('T')[0]`) in `src/routes/dashboard.js` and others flips at 8pm ET — a third clock.
7. **DATE-column serialization off-by-one**: node-postgres returns `DATE` as a JS Date at *Berlin local midnight* (= 22:00/23:00 UTC the previous day); `d.toISOString().split('T')[0]` then emits the **previous day**. Affects `src/routes/dashboard.js` (~lines 335, 379, 478, 542, 779, 799, 812) and `src/routes/products.js:1006` wherever the value is a true DATE column.
8. **Two duplicate luxon utils disagree**: `TimeManager.getDayEnd` = next 1am −1 *minute* (59.999s hole); `timeUtils.getDayEnd` = −1 ms. `TimeManager.groupEventsByInterval` groups by `startOf('day')` in the event string's own zone — neither ET nor the 1am rule.
9. **Routes that ignore the convention**: `discounts.js` (zoneless `DateTime.fromISO` + `startOf('day')` = Berlin midnight), `customers.js` (MySQL `NOW()` windows), `events.js` hourly chart (Central hour labels shown to an Eastern office).
10. **Three mysql2 timezone configs for the same DB**: import `'-05:00'` (+ dynamic outbound fix), acot-server `'Z'`, `src/utils/dbConnection.js` + `src/routes/import.js` `'Z'`.
11. **Frontend**: custom ranges are browser-local midnight→23:59:59 (not business days); Forecasting's "Last Month" preset = *trailing* month while everywhere else it's *calendar* month; BlackFridayDashboard does UTC date math; 5 different range-option vocabularies; `date-range-picker-narrow.tsx` is dead code.

---

## Phase 1 — PostgreSQL & Node clocks (highest leverage, smallest diff)

**1.1 Set PG database timezone to business time.**

```sql
ALTER DATABASE inventory_db SET timezone = 'America/Chicago';
```

Then reconnect all pools (PM2 reload, see gotcha note at bottom; the 15-min cron picks it up on next run automatically since it opens fresh connections).

Effect: every `CURRENT_DATE`, `NOW()::date`, `ts::date`, `DATE(ts)`, `date_trunc('day', ts)` in routes and metrics SQL becomes business-correct *without touching the queries*. Explicit `AT TIME ZONE 'America/Chicago'` expressions are unaffected (they're absolute) — leave them; they now read as documentation.

- [ ] Run `ALTER DATABASE`; verify with `SHOW timezone` from a fresh `psql` and from the app pool (add a temporary log of `SELECT current_setting('timezone')` on pool connect).
- [ ] Belt-and-suspenders: also set it in the pool config in `src/utils/db.js` (`options: '-c TimeZone=America/Chicago'` or a `SET TIME ZONE` on connect) so a DB restore/migration can't silently revert it. Same for the pg pools in `scripts/import/`* and `scripts/forecast/run_forecast.js`.
- [ ] Sweep for anything that *wanted* UTC or Berlin semantics: `grep -rn "NOW()\|CURRENT_DATE\|CURRENT_TIMESTAMP" src/ scripts/ db/`. Audit found none — confirm. (Pipeline bookkeeping like `last_calculated >= NOW() - INTERVAL '5 minutes'` is interval-based and unaffected.)
- [ ] Check `forecast_engine.py`: any `datetime.now()`/`date.today()` used to anchor horizons should become Chicago-aware (`zoneinfo.ZoneInfo('America/Chicago')`). The engine writes `forecast_date` DATE rows — those must mean business dates.

**1.2 Set Node process TZ to business time.**
Add `TZ=America/Chicago` to the PM2 ecosystem env for `inventory-server` and `dashboard-server` (and the cron wrapper for `full-update.js`).

Why: (a) fixes defect #7 — node-postgres will materialize DATE columns at *Chicago* midnight, which round-trips through `toISOString().split('T')[0]` to the **same** date (Chicago is west of UTC); (b) makes incidental `new Date()` / `setHours` / `getHours` math business-aligned instead of Berlin.

- [ ] Add `TZ` to `ecosystem.config.cjs` env blocks; `pm2 reload ecosystem.config.cjs --update-env` (NOT `pm2 restart` — see PM2 env gotcha).
- [ ] Even with TZ set, replace the `.toISOString().split('T')[0]` idiom on DATE columns with `::text` in the SQL (cheapest, unambiguous) or a `formatDateCol()` helper — do this opportunistically in Phase 4; the TZ change makes it correct immediately.
- [ ] Note: `new Date().toISOString()` still yields UTC instants — fine for instants, never for "today". `businessToday()` from the shared module (Phase 3) is the only sanctioned "today".

**Verification (Phase 1):**

- [ ] Before/after at ~7pm ET (the danger window): `SELECT CURRENT_DATE` must equal the Chicago date, not tomorrow.
- [ ] `GET /api/dashboard` forecast series: dates must match `SELECT forecast_date::text` directly from PG (defect #7 regression check).
- [ ] Run a full `full-update.js` cycle; diff `product_metrics` sales_30d counts for a handful of pids against hand-computed Chicago windows.

## Phase 2 — Fix the acot-server hour-late bug (biggest visible-number impact)

All in `dashboard/acot-server/` (= `/var/www/inventory/dashboard/acot-server/`).

**2.1 `utils/timeUtils.js`:**

- [ ] Replace `DB_TIMEZONE = 'UTC-05:00'` with `DB_TIMEZONE = 'America/Chicago'`.
- [ ] `toDatabaseSqlString()`: `normalized.setZone(DB_TIMEZONE).toFormat(...)` — **delete `keepLocalTime: true*`*. 1:00am ET now correctly serializes as `00:00:00` (Central).
- [ ] `parseBusinessDate()` / `formatMySQLDate()`: same zone constant; they become DST-correct automatically.
- [ ] `getDayEnd()`: switch to half-open — return next day-start; change `getTimeRangeConditions` to emit `date_placed >= ? AND date_placed < ?`. (Eliminates the −1ms endpoint and any boundary-order ambiguity.)
- [ ] Keep `BUSINESS_DAY_START_HOUR = 1` ET — it's the same convention; the *conversion* was the bug.

**2.2 Daily bucketing in routes — remove the hand-rolled hour shift:**

- [ ] `routes/operations-metrics.js` (~~184–214) and `routes/employee-metrics.js` (~~324–351): `DATE_FORMAT(DATE_SUB(col, INTERVAL 1 HOUR), '%Y-%m-%d')` → `DATE(col)` for `pt.createddate`, `o.date_shipped`, `tc.TimeStamp` (all Central literals — verified). Update the misleading "Eastern timezone" comments.
- [ ] Where the JS then re-parses those date strings (`DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE })`) keep zone ET only for *labeling*; the string itself is already the business date — pass it through.

**2.3 `routes/events.js`:**

- [ ] Line ~639: `DateTime.fromSQL(value, { zone: 'UTC-05:00' })` → `{ zone: 'America/Chicago' }` (or route through `parseBusinessDate`).
- [ ] Hourly chart (today/yesterday): currently `HOUR(date_placed)` returns **Central** hours; office reads the axis as Eastern. Convert for display: emit the business timestamp and format hours in ET on the frontend, or `HOUR(CONVERT_TZ(date_placed,'America/Chicago','America/New_York'))` if the MySQL tz tables are loaded (verify with `SELECT CONVERT_TZ(NOW(),'America/Chicago','America/New_York')`; fall back to `ADDTIME(date_placed, '01:00:00')` — ET = CT + 1h always). Same for the `currentHour` source.
- [ ] Previous-period custom math (~553–561) is instant-math and stays as-is once bounds are correct.

**2.4 Routes not on the util:**

- [ ] `routes/discounts.js` (~62–85): replace zoneless `DateTime.fromISO` + Berlin `startOf('day')`/`endOf('day')` with `getTimeRangeConditions('custom', …)` / business-day helpers.
- [ ] `routes/customers.js` (~238–244): `DATE_SUB(NOW(), INTERVAL 3 MONTH)` is a rough trailing window — acceptable; add a comment that `NOW()` here is Central and that's intended. (Or switch to bound params from the util for uniformity.)
- [ ] `routes/payroll-metrics.js`: punches must be interpreted as Central. Either (preferred, see 2.5) get strings from the driver and parse with `DateTime.fromSQL(s, { zone: 'America/Chicago' })`, or correct the `'Z'`-mangled Dates explicitly. All pay-period boundary math stays in ET — that's display convention; the parse is what's broken. Re-test: a 7:58am-CT punch must render 8:58am ET and land on the right pay-period day; test an overnight punch (e.g. 00:30 CT).

**2.5 Driver config — make values unambiguous:**

- [ ] `db/connection.js`: add `dateStrings: true` (and drop `timezone: 'Z'`). All DATETIME/TIMESTAMP values arrive as literal strings; every consumer parses through the shared helper with zone Chicago. This converts the implicit-zone footgun into a compile-time-visible parse step. Sweep acot-server routes for `instanceof Date` assumptions and `new Date(row.X)` on datetime fields and route them through the parser.

**Verification (Phase 2):**

- [ ] Live-DB boundary check: place/find an order with `date_placed` between `00:00` and `01:00` CT (i.e. 1–2am ET); `timeRange=today` must include orders from `00:00:00` CT onward. Before the fix it starts at `01:00:00` CT.
- [ ] Compare `/stats?timeRange=yesterday` totals before/after; expect ~the 1am–2am ET hour to migrate between days.
- [ ] Employee daily hours for a known day vs the punch list by hand.
- [ ] Flush the dashboard Redis cache after deploy (cached responses are keyed by `timeRange` and hold pre-fix numbers).

## Phase 3 — One shared business-time module (kill the duplicate utils)

Create `inventory-server/shared/business-time/` (consistent with the shared-lib location and the side-service convention — this is a leaf dependency, not a middle layer). Export, roughly:

```
BUSINESS_TZ = 'America/Chicago'   // canonical
OFFICE_TZ   = 'America/New_York'  // display only
businessToday()                   // Chicago calendar date (DateTime)
businessDayStart(d) / businessDayRange(d)        // [start, nextStart)
rangeForPreset(name, now?)        // today|yesterday|last7days|...|lastMonth — one vocabulary
previousRange(name|range)
toMySqlBound(dt)                  // Central wall-clock 'YYYY-MM-DD HH:mm:ss'
parseMySql(s)                     // zone Chicago → DateTime
businessDateOf(instant)           // → 'YYYY-MM-DD'
formatOffice(dt, fmt)             // ET display formatting
```

- [ ] Port `timeUtils.js` consumers (events/operations/employee/discounts/payroll) to it; delete `dashboard/acot-server/utils/timeUtils.js`.
- [ ] Port `TimeManager` consumers (klaviyo routes + `events.service.js`, `campaigns.service.js`, `reporting.service.js`, `redis.service.js`) to it; delete `dashboard/utils/time.utils.js`. Klaviyo API bounds stay UTC ISO (that part was right).
- [ ] Fix `groupEventsByInterval` while porting: group by `businessDateOf(eventInstant)` (day) and `setZone(OFFICE_TZ)` for hour/week/month — never the event string's own offset.
- [ ] One range vocabulary: the union of today's two sets — `today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days, previous7/30/90days, custom`. Both servers accept exactly this list; unknown → 400, not a silent default.
- [ ] ESM/CJS: dashboard tree is ESM, `src/` is CJS — publish the module dual (or `.mjs` with a tiny CJS wrapper), same pattern as existing shared lib.
- [ ] Klaviyo verification (per Matt): pull one known recent order event and confirm `attributes.datetime` is UTC (`Z`/+00:00) and matches the PG `orders.date` instant. If it ever arrives as ET-naive, add an explicit zone at the single parse point in the module.

## Phase 4 — inventory-server `src/routes` cleanup (mostly mechanical after Phase 1)

- [ ] `dashboard.js`: replace `new Date()`/`toISOString().split('T')[0]` "today"/default-range derivations (~309–316, 1041–1043, 1115–1120) with `businessToday()` / date-only strings; emit DATE columns via `::text` (lines ~335, 379, 478, 542, 769–815).
- [ ] `analytics.js`: default ranges (~19–24, 180–184) — drop the UTC truncation dance; accept `YYYY-MM-DD` business dates, default via `businessToday()`. `o.date::date` joins become business-correct after Phase 1 — change to the explicit `AT TIME ZONE 'America/Chicago'` idiom anyway in the hot endpoints so they don't regress if session TZ ever changes.
- [ ] `orders.js`: `fromDate/toDate` (~~13–45) — treat date-only input as business-day bounds (start of `from`'s business day to start of day after `to`), not UTC midnights; `DATE(date)` windows (~~115–131) fine post-Phase 1.
- [ ] `products.js:1006`, `purchase-orders.js:719/772` (UTC-today defaults), `repeat-orders.js`, `newsletter.js`: same two substitutions (business today; `::text` for DATE output). All `CURRENT_DATE` usages are correct after Phase 1 — leave them.
- [ ] `metrics-new/*.sql`: window anchors (`CURRENT_DATE - INTERVAL '59 days'` etc., `update_product_metrics.sql:11` `_current_date`) become business-correct via Phase 1 — no edits required; add a header comment to each file stating the session-TZ assumption.
- [ ] Standardize `src/utils/dbConnection.js` and the inline config in `src/routes/import.js` to `dateStrings: true` + shared parsing (they currently use `timezone: 'Z'`; consumers are product-text-centric so risk is low, but one convention).
- [ ] Import pipeline (`scripts/import/`): keep `adjustDateForMySQL()` for outbound params (it's the correct dynamic approach) — or migrate to `dateStrings: true` + `toMySqlBound()` for symmetry. Either way, inbound DATETIME *reads* must stop being parsed at fixed `-05:00`: audit `purchase-orders.js`/`daily-deals.js` for fields that survive into PG as instants and route them through `parseMySql()`. (`orders` uses `stamp` TIMESTAMP — wire-format follows session tz = Central; same parse rule applies.)

## Phase 5 — Frontend consolidation

**Principle: the server owns all boundary math.** The client sends either a named range or date-only strings (`YYYY-MM-DD`, meaning business dates); it never sends `toISOString()` of a browser-local midnight.

- [x] **Two sanctioned selector kits** — *implemented 2026-06-13* at `inventory/src/components/dashboard/shared/{BusinessRangeSelect,HorizonTabs}.tsx`.
  1. `BusinessRangeSelect` — the `TIME_RANGES` presets. Migrated StatCards (×2), SalesChart, ProductGrid, KlaviyoCampaigns (all were identical `<Select>`-over-`TIME_RANGES` blocks). Shipped preset-only: those cards never had a custom-range UI, and FinancialOverview/OperationsMetrics keep their own natural-language period pickers (boundary math already fixed earlier in Phase 5). A `Custom…` popover can be added to the shared component later if a card needs it.
  2. `HorizonTabs` — trailing-day windows (`7 / 30 / 90 / 365`, label map incl. `14D`/`1Y`). Migrated SalesMetrics (`7|30|90`), InventoryTrends/ValueTrend (`30|90|365`), InventoryFlow (`30|90`). **Corrections vs. this plan's list:** GrowthMomentum has *no* period selector (nothing to migrate), and the `30|90|'year'` union is actually ForecastMetrics — left alone because its 3rd option is a forward EOY forecast horizon, not a trailing window. Each card keeps its own fetch; this was a pure presentational/DRY swap. The GA4/Meta day-granular cards (MetaCampaigns/UserBehavior/Analytics) are left for the deferred Phase 6 footnote pass.
- [ ] `FinancialOverview` / `OperationsMetrics` custom periods: send the parsed period descriptor (`{type, startYear, startMonth|startQuarter, count}` from `naturalLanguagePeriod`) or derived date-only strings — not browser-midnight ISO instants (currently `setHours(0,0,0,0)`/`23:59:59.999` + `toISOString()`, FinancialOverview ~lines 560–620, 762–763). Server resolves to 1am-ET bounds. Client-side bucket alignment (`setHours(0,…)` ~390/432) should key on server-provided business-date strings instead.
- [ ] `MetaCampaigns` / `UserBehaviorDashboard` / `AnalyticsDashboard`: keep numeric `7/14/30/90` where the upstream API is day-granular (GA4/Meta), but render through `HorizonTabs` for visual consistency; label the GA/Meta cards' footnote "calendar days, midnight ET" (platform limitation — accepted deviation, see Phase 6).
- [ ] Forecasting `DateRangePickerQuick`: rename presets to "Trailing month / 3 / 6 / 12 months" (they are `addMonths(now, -N)`, not calendar months) or convert to calendar months — decide one; today the label collides with the dashboards' calendar "Last Month".
- [ ] Delete `src/components/ui/date-range-picker-narrow.tsx` (zero importers).
- [ ] `BlackFridayDashboard`: replace `getUTCFullYear/getUTCMonth` anchors with business-date helpers (a small `businessToday()` util in `src/utils/` — Chicago date via `Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' })`).
- [ ] `lib/dashboard/constants.js`: keep `TIME_RANGES` as the one preset list; `formatDateForInput`/`parseDateFromInput` stay (datetime-local is inherently browser-local) but custom values must be converted to date-only/business params at the request boundary, not `toISOString()`.
- [ ] `DateTime.jsx` (dashboard clock): browser-local is fine for a wall clock; pin to `America/New_York` via `toLocaleString` options so a remote viewer sees office time.

## Phase 6 — External services: document accepted deviations

These platforms bucket at **midnight ET**, not 1am ET. The 1-hour difference is not worth fighting their APIs; record it instead.

- [ ] GA4: property TZ = America/New_York; `NdaysAgo` = calendar days midnight ET. Accepted deviation (affects 0:00–1:00am ET traffic attribution only).
- [ ] Meta insights: `time_zone: 'America/New_York'` — same deviation, same note.
- [ ] Klaviyo: data fetched with exact UTC bounds from our own range math → fully on-convention once Phase 3 lands (plus the UTC-return verification).
- [ ] Add a `docs/TIME.md` (or a section in `.claude/CLAUDE.md`) stating: the convention, the approved idioms table, the accepted deviations, and "never hand-roll an hour offset."

## Rollout order & risk notes

1. Phase 2 (acot-server fix) is independent of everything — ship first; it corrects the most-watched numbers. Flush Redis after deploy.
2. Phase 1 next, off-hours (ideally before 6pm ET so Berlin/Chicago dates agree during the change); pause the auto-update (`touch inventory-server/.pause-auto-update`), apply, run one supervised `full-update.js`, compare metrics row counts, unpause.
3. Phases 3–4 together (module + ports), then 5, then 6.
4. PM2: env changes need `pm2 reload ecosystem.config.cjs --update-env` (restart won't pick up new env, except dashboard-server which loads dotenv itself).
5. DST regression dates for tests: **Nov 1 2026** (fall back) and **Mar 14 2027** (spring forward) — boundary tests for `rangeForPreset('today')`, `toMySqlBound`, and the metrics windows on both.
6. Expect small visible shifts in historical dashboard numbers after Phase 2 (the 1am–2am ET hour moves to its correct day) and after Phase 1 (evening-run metrics windows stop drifting). Worth a heads-up to anyone comparing screenshots.

## Decisions needed (Matt)

- [ ] Forecasting quick-presets: trailing (rename) or calendar months? trailing
- [ ] Hourly "today" chart axis: display Eastern hours (recommended) — confirm. - yes
- [ ] Custom calendar periods (e.g. "March 2026") = business month (Mar 1 1am ET → Apr 1 1am ET, recommended) — confirm, so FinancialOverview presets and custom agree. yes