# 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).
