# TIME.md — the business-time convention Adopted 2026-06-12 (see `TIME_UNIFICATION_PLAN.md` at the repo root for the audit and rollout). **Never hand-roll an hour offset.** ## The convention **A business day runs 1:00am Eastern → 1:00am 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.** - `BUSINESS_TZ = 'America/Chicago'` — canonical, used for all boundary math. - `OFFICE_TZ = 'America/New_York'` — display only (the office reads ET). The single source of truth is `shared/business-time/index.js` (`@inventory/shared/business-time`). The frontend mirror for the few client-side needs is `inventory/src/utils/businessTime.ts`. ## Approved idioms | Context | Correct idiom | | --- | --- | | PG `timestamptz` → business date | `(ts AT TIME ZONE 'America/Chicago')::date`, or plain `ts::date` / `CURRENT_DATE` (the session TZ is pinned to America/Chicago in every pool config AND as the `inventory_db` database default) | | MySQL DATETIME literal (stores **Central wall-clock**) → business date | `DATE(col)` / `DATE_FORMAT(col, '%Y-%m-%d')` — **no hour shift** | | MySQL WHERE bounds for business days | Central wall-clock strings via `toMySqlBound()`: `col >= ? AND col < ?` | | MySQL hour-of-day for the office | `HOUR(ADDTIME(col, '01:00:00'))` — ET = CT + 1h year-round | | Luxon | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset (wrong every winter) | | Intervals | **Half-open** `[start, nextStart)`. No `-1 minute` / `-1 ms` endpoints (legacy exception: the `TimeManager` compat class keeps an inclusive −1ms end for its existing consumers) | | "Today" in JS | `businessTodayStr()` / `businessToday()` from the shared module — never `new Date().toISOString().split('T')[0]` (UTC) | | node-postgres DATE columns → string | `formatDateCol(value)` (local-time formatting round-trips correctly) or `::text` in SQL — never `.toISOString().split('T')[0]` (UTC shifts a day) | | mysql2 driver config | `dateStrings: true` + parse with `parseMySql()` (zone Chicago). The import pipeline instead pins a **dynamic** Chicago offset (`currentChicagoOffset()` in `scripts/import-from-prod.js`) | | Client → server | Named range presets, or **date-only strings** (`YYYY-MM-DD` = business dates). Never `toISOString()` of a browser-local midnight | ## Range vocabulary (both servers, one list) `today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days, previous7days, previous30days, previous90days, custom` `custom` takes `startDate`/`endDate` as date-only business dates (preferred) or zoned ISO instants (used as-is, end exclusive). Calendar periods ("March 2026") mean the business month: Mar 1 1am ET → Apr 1 1am ET. ## Accepted deviations (documented, not fought) - **GA4**: property TZ = America/New_York; `NdaysAgo` = calendar days, midnight ET. Affects only 0:00–1:00am ET traffic attribution. - **Meta insights**: `time_zone: 'America/New_York'` — same 1-hour deviation, same note. - **Klaviyo**: fetched with exact UTC bounds computed from our own range math — fully on-convention. API returns UTC datetimes. - **Pay periods** (payroll dashboard): 14-day periods aligned to **midnight ET** Sundays — an HR display convention, intentionally not 1am-ET days. ## Infrastructure pins (belt-and-suspenders) 1. `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` (database default). 2. Every pg Pool passes `options: '-c TimeZone=America/Chicago'` (`src/utils/db.js`, `shared/db/pg.js`, `scripts/metrics-new/utils/db.js`, `scripts/calculate-metrics-new.js`, `scripts/import/utils.js`). 3. Node processes run with `TZ=America/Chicago` (`/var/www/ecosystem.config.cjs` env blocks; `scripts/full-update.js` and `scripts/forecast/run_forecast.js` set it themselves for cron contexts). 4. `forecast_engine.py` anchors on `business_today()` (zoneinfo Chicago).