24 KiB
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 mirrorinventory/src/utils/businessTime.ts); both duplicate utils deleted; acot-server hour-late bug fixed;dateStrings: trueon all mysql2 configs except the import pipeline (dynamic Chicago offset instead);forecast_engine.pyanchors onbusiness_today(); pg pools pinTimeZone=America/Chicago;/var/www/ecosystem.config.cjshasTZ: 'America/Chicago'(backup at~/ecosystem.config.cjs.bak.2026-06-12); supervised import+metrics cycle ran clean. Docs atinventory-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 ④ frontendnpm 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_shippedare DATETIME storing Central wall-clock ✓ (recent order:date_placed 18:28at 23:45 UTC).timeclock.TimeStampstores 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_dbsession TZ = Europe/Berlin ✓ (SHOW timezone). - Node processes on netcup run with system TZ Europe/Berlin (no
TZin.env/ecosystem). - Browsers/office = America/New_York.
- GA4 property reporting TZ = America/New_York (per Matt); API
NdaysAgoresolves 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
- acot-server dashboard windows are 1 hour late (today = 2am–2am ET):
toDatabaseSqlString()indashboard/acot-server/utils/timeUtils.jspasses Eastern wall-clock (viakeepLocalTime: true+ fixedUTC-05:00) to a Central-literal DB. - Fixed
UTC-05:00offsets intimeUtils.js(DB_TIMEZONE) andevents.js:639— wrong every winter (CST = UTC-6). **DATE_SUB(col, INTERVAL 1 HOUR)daily bucketing** in operations/employee metrics — assumed Eastern storage; columns are Central, so buckets are also 1h late.- payroll-metrics punch parsing: mysql2
timezone: 'Z'turns07:58Central into07: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. - PG routes use Berlin "today": every
CURRENT_DATE/NOW()/::date/DATE(col)insrc/routes/* and the window anchors inscripts/metrics-new/*.sqlflip to the next business day at 6pm ET. - JS UTC "today" (
new Date().toISOString().split('T')[0]) insrc/routes/dashboard.jsand others flips at 8pm ET — a third clock. - DATE-column serialization off-by-one: node-postgres returns
DATEas 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. Affectssrc/routes/dashboard.js(~lines 335, 379, 478, 542, 779, 799, 812) andsrc/routes/products.js:1006wherever the value is a true DATE column. - Two duplicate luxon utils disagree:
TimeManager.getDayEnd= next 1am −1 minute (59.999s hole);timeUtils.getDayEnd= −1 ms.TimeManager.groupEventsByIntervalgroups bystartOf('day')in the event string's own zone — neither ET nor the 1am rule. - Routes that ignore the convention:
discounts.js(zonelessDateTime.fromISO+startOf('day')= Berlin midnight),customers.js(MySQLNOW()windows),events.jshourly chart (Central hour labels shown to an Eastern office). - 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'. - 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.tsxis 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 withSHOW timezonefrom a freshpsqland from the app pool (add a temporary log ofSELECT 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 aSET TIME ZONEon connect) so a DB restore/migration can't silently revert it. Same for the pg pools inscripts/import/* andscripts/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 likelast_calculated >= NOW() - INTERVAL '5 minutes'is interval-based and unaffected.) - Check
forecast_engine.py: anydatetime.now()/date.today()used to anchor horizons should become Chicago-aware (zoneinfo.ZoneInfo('America/Chicago')). The engine writesforecast_dateDATE 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
TZtoecosystem.config.cjsenv blocks;pm2 reload ecosystem.config.cjs --update-env(NOTpm2 restart— see PM2 env gotcha). - Even with TZ set, replace the
.toISOString().split('T')[0]idiom on DATE columns with::textin the SQL (cheapest, unambiguous) or aformatDateCol()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_DATEmust equal the Chicago date, not tomorrow. GET /api/dashboardforecast series: dates must matchSELECT forecast_date::textdirectly from PG (defect #7 regression check).- Run a full
full-update.jscycle; diffproduct_metricssales_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'withDB_TIMEZONE = 'America/Chicago'. toDatabaseSqlString():normalized.setZone(DB_TIMEZONE).toFormat(...)— **deletekeepLocalTime: true**. 1:00am ET now correctly serializes as00:00:00(Central).parseBusinessDate()/formatMySQLDate(): same zone constant; they become DST-correct automatically.getDayEnd(): switch to half-open — return next day-start; changegetTimeRangeConditionsto emitdate_placed >= ? AND date_placed < ?. (Eliminates the −1ms endpoint and any boundary-order ambiguity.)- Keep
BUSINESS_DAY_START_HOUR = 1ET — 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) androutes/employee-metrics.js(~~324–351):DATE_FORMAT(DATE_SUB(col, INTERVAL 1 HOUR), '%Y-%m-%d')→DATE(col)forpt.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 throughparseBusinessDate). - 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, orHOUR(CONVERT_TZ(date_placed,'America/Chicago','America/New_York'))if the MySQL tz tables are loaded (verify withSELECT CONVERT_TZ(NOW(),'America/Chicago','America/New_York'); fall back toADDTIME(date_placed, '01:00:00')— ET = CT + 1h always). Same for thecurrentHoursource. - 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 zonelessDateTime.fromISO+ BerlinstartOf('day')/endOf('day')withgetTimeRangeConditions('custom', …)/ business-day helpers.routes/customers.js(~238–244):DATE_SUB(NOW(), INTERVAL 3 MONTH)is a rough trailing window — acceptable; add a comment thatNOW()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 withDateTime.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: adddateStrings: true(and droptimezone: '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 forinstanceof Dateassumptions andnew 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_placedbetween00:00and01:00CT (i.e. 1–2am ET);timeRange=todaymust include orders from00:00:00CT onward. Before the fix it starts at01:00:00CT. - Compare
/stats?timeRange=yesterdaytotals 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
timeRangeand 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.jsconsumers (events/operations/employee/discounts/payroll) to it; deletedashboard/acot-server/utils/timeUtils.js. - Port
TimeManagerconsumers (klaviyo routes +events.service.js,campaigns.service.js,reporting.service.js,redis.service.js) to it; deletedashboard/utils/time.utils.js. Klaviyo API bounds stay UTC ISO (that part was right). - Fix
groupEventsByIntervalwhile porting: group bybusinessDateOf(eventInstant)(day) andsetZone(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.mjswith a tiny CJS wrapper), same pattern as existing shared lib. - Klaviyo verification (per Matt): pull one known recent order event and confirm
attributes.datetimeis UTC (Z/+00:00) and matches the PGorders.dateinstant. 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: replacenew Date()/toISOString().split('T')[0]"today"/default-range derivations (~309–316, 1041–1043, 1115–1120) withbusinessToday()/ 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; acceptYYYY-MM-DDbusiness dates, default viabusinessToday().o.date::datejoins become business-correct after Phase 1 — change to the explicitAT 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 offrom's business day to start of day afterto), 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;::textfor DATE output). AllCURRENT_DATEusages 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.jsand the inline config insrc/routes/import.jstodateStrings: true+ shared parsing (they currently usetimezone: 'Z'; consumers are product-text-centric so risk is low, but one convention). - Import pipeline (
scripts/import/): keepadjustDateForMySQL()for outbound params (it's the correct dynamic approach) — or migrate todateStrings: true+toMySqlBound()for symmetry. Either way, inbound DATETIME reads must stop being parsed at fixed-05:00: auditpurchase-orders.js/daily-deals.jsfor fields that survive into PG as instants and route them throughparseMySql(). (ordersusesstampTIMESTAMP — 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 kits — implemented 2026-06-13 at
inventory/src/components/dashboard/shared/{BusinessRangeSelect,HorizonTabs}.tsx.BusinessRangeSelect— theTIME_RANGESpresets. Migrated StatCards (×2), SalesChart, ProductGrid, KlaviyoCampaigns (all were identical<Select>-over-TIME_RANGESblocks). 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). ACustom…popover can be added to the shared component later if a card needs it.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 the30|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/OperationsMetricscustom periods: send the parsed period descriptor ({type, startYear, startMonth|startQuarter, count}fromnaturalLanguagePeriod) or derived date-only strings — not browser-midnight ISO instants (currentlysetHours(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 numeric7/14/30/90where the upstream API is day-granular (GA4/Meta), but render throughHorizonTabsfor 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 areaddMonths(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: replacegetUTCFullYear/getUTCMonthanchors with business-date helpers (a smallbusinessToday()util insrc/utils/— Chicago date viaIntl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' })).lib/dashboard/constants.js: keepTIME_RANGESas the one preset list;formatDateForInput/parseDateFromInputstay (datetime-local is inherently browser-local) but custom values must be converted to date-only/business params at the request boundary, nottoISOString().DateTime.jsx(dashboard clock): browser-local is fine for a wall clock; pin toAmerica/New_YorkviatoLocaleStringoptions 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
- Phase 2 (acot-server fix) is independent of everything — ship first; it corrects the most-watched numbers. Flush Redis after deploy.
- 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 supervisedfull-update.js, compare metrics row counts, unpause. - Phases 3–4 together (module + ports), then 5, then 6.
- 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). - 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. - 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