Files
inventory/TIME_UNIFICATION_PLAN.md
2026-06-17 15:06:38 -04:00

217 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 16 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:0307: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 = 2am2am 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 (midnight5am 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` (~~184214) and `routes/employee-metrics.js` (~~324351): `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 (~553561) is instant-math and stays as-is once bounds are correct.
**2.4 Routes not on the util:**
- [ ] `routes/discounts.js` (~6285): replace zoneless `DateTime.fromISO` + Berlin `startOf('day')`/`endOf('day')` with `getTimeRangeConditions('custom', …)` / business-day helpers.
- [ ] `routes/customers.js` (~238244): `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. 12am 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 1am2am 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 (~309316, 10411043, 11151120) with `businessToday()` / date-only strings; emit DATE columns via `::text` (lines ~335, 379, 478, 542, 769815).
- [ ] `analytics.js`: default ranges (~1924, 180184) — 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` (~~1345) — 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 (~~115131) 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 560620, 762763). 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:001: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 34 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 1am2am 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