# 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 `