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

24 KiB
Raw Permalink Blame History

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.

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.

  • Two sanctioned selector kitsimplemented 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