From 54a2460eacfcea567381ab88df516ec618b8c607 Mon Sep 17 00:00:00 2001
From: Matt
Date: Wed, 17 Jun 2026 15:06:38 -0400
Subject: [PATCH] Time unification
---
.gitignore | 4 +
TIME_UNIFICATION_PLAN.md | 217 +++++++
.../dashboard/acot-server/db/connection.js | 7 +-
.../dashboard/acot-server/routes/customers.js | 2 +
.../dashboard/acot-server/routes/discounts.js | 23 +-
.../acot-server/routes/employee-metrics.js | 73 ++-
.../dashboard/acot-server/routes/events.js | 132 ++--
.../acot-server/routes/operations-metrics.js | 57 +-
.../acot-server/routes/payroll-metrics.js | 35 +-
.../dashboard/acot-server/utils/timeUtils.js | 317 ----------
.../routes/klaviyo/campaigns.routes.js | 2 +-
.../dashboard/routes/klaviyo/events.routes.js | 2 +-
.../routes/klaviyo/reporting.routes.js | 2 +-
.../services/klaviyo/campaigns.service.js | 2 +-
.../services/klaviyo/events.service.js | 2 +-
.../services/klaviyo/redis.service.js | 2 +-
.../services/klaviyo/reporting.service.js | 2 +-
.../dashboard/utils/time.utils.js | 448 --------------
inventory-server/docs/TIME.md | 67 +++
inventory-server/package-lock.json | 10 +
inventory-server/package.json | 1 +
.../scripts/calculate-metrics-new.js | 6 +
.../forecast_engine.cpython-312.pyc | Bin 81621 -> 82084 bytes
.../scripts/forecast/forecast_engine.py | 20 +-
.../scripts/forecast/run_forecast.js | 4 +
inventory-server/scripts/full-update.js | 4 +
inventory-server/scripts/import-from-prod.js | 18 +-
inventory-server/scripts/import/utils.js | 16 +-
.../metrics-new/calculate_brand_metrics.sql | 4 +
.../calculate_category_metrics.sql | 4 +
.../metrics-new/calculate_vendor_metrics.sql | 4 +
.../metrics-new/update_daily_snapshots.sql | 4 +
.../update_lifecycle_forecasts.sql | 4 +
.../metrics-new/update_periodic_metrics.sql | 4 +
.../metrics-new/update_product_metrics.sql | 4 +
.../scripts/metrics-new/utils/db.js | 3 +
.../shared/business-time/index.js | 562 ++++++++++++++++++
inventory-server/shared/db/pg.js | 3 +
inventory-server/shared/package.json | 8 +-
inventory-server/src/routes/analytics.js | 23 +-
inventory-server/src/routes/dashboard.js | 73 +--
inventory-server/src/routes/import.js | 5 +-
inventory-server/src/routes/orders.js | 14 +-
inventory-server/src/routes/products.js | 3 +-
.../src/routes/purchase-orders.js | 5 +-
inventory-server/src/utils/db.js | 8 +-
inventory-server/src/utils/dbConnection.js | 5 +-
.../components/analytics/InventoryFlow.tsx | 17 +-
.../components/analytics/InventoryTrends.tsx | 17 +-
.../analytics/InventoryValueTrend.tsx | 17 +-
.../src/components/dashboard/DateTime.jsx | 32 +-
.../dashboard/FinancialOverview.tsx | 8 +-
.../components/dashboard/KlaviyoCampaigns.jsx | 22 +-
.../dashboard/OperationsMetrics.tsx | 8 +-
.../src/components/dashboard/ProductGrid.jsx | 24 +-
.../src/components/dashboard/SalesChart.jsx | 24 +-
.../src/components/dashboard/StatCards.jsx | 35 +-
.../dashboard/shared/BusinessRangeSelect.tsx | 64 ++
.../dashboard/shared/HorizonTabs.tsx | 60 ++
.../forecasting/DateRangePickerQuick.tsx | 8 +-
.../src/components/overview/SalesMetrics.tsx | 10 +-
.../ui/date-range-picker-narrow.tsx | 135 -----
inventory/src/pages/BlackFridayDashboard.tsx | 9 +-
inventory/src/pages/DiscountSimulator.tsx | 25 +-
inventory/src/pages/Forecasting.tsx | 6 +-
inventory/src/utils/businessTime.ts | 33 +
inventory/tsconfig.tsbuildinfo | 2 +-
67 files changed, 1442 insertions(+), 1329 deletions(-)
create mode 100644 TIME_UNIFICATION_PLAN.md
delete mode 100644 inventory-server/dashboard/acot-server/utils/timeUtils.js
delete mode 100644 inventory-server/dashboard/utils/time.utils.js
create mode 100644 inventory-server/docs/TIME.md
create mode 100644 inventory-server/shared/business-time/index.js
create mode 100644 inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx
create mode 100644 inventory/src/components/dashboard/shared/HorizonTabs.tsx
delete mode 100644 inventory/src/components/ui/date-range-picker-narrow.tsx
create mode 100644 inventory/src/utils/businessTime.ts
diff --git a/.gitignore b/.gitignore
index 6bc1138..0235abd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,10 @@ dashboard-server/meta-server/._package-lock.json
dashboard-server/meta-server/._services
*.tsbuildinfo
+# Python bytecode
+__pycache__/
+*.pyc
+
uploads/*
uploads/**/*
**/uploads/*
diff --git a/TIME_UNIFICATION_PLAN.md b/TIME_UNIFICATION_PLAN.md
new file mode 100644
index 0000000..aac8164
--- /dev/null
+++ b/TIME_UNIFICATION_PLAN.md
@@ -0,0 +1,217 @@
+# 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 ``-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 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 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: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
+
+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 3–4 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 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
\ No newline at end of file
diff --git a/inventory-server/dashboard/acot-server/db/connection.js b/inventory-server/dashboard/acot-server/db/connection.js
index 90d6773..c5dd527 100644
--- a/inventory-server/dashboard/acot-server/db/connection.js
+++ b/inventory-server/dashboard/acot-server/db/connection.js
@@ -211,7 +211,12 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
- timezone: 'Z'
+ // DATETIME columns in this DB store Central wall-clock literals. Return
+ // them as literal strings so consumers parse them explicitly with
+ // parseMySql() (zone America/Chicago) from shared/business-time — never
+ // let the driver guess a zone (the old `timezone: 'Z'` mangled every
+ // value by labeling Central wall-clock as UTC).
+ dateStrings: true
};
return new Promise((resolve, reject) => {
diff --git a/inventory-server/dashboard/acot-server/routes/customers.js b/inventory-server/dashboard/acot-server/routes/customers.js
index 8cfa11d..2dab28e 100644
--- a/inventory-server/dashboard/acot-server/routes/customers.js
+++ b/inventory-server/dashboard/acot-server/routes/customers.js
@@ -230,6 +230,8 @@ router.get('/:cid/orders', async (req, res) => {
try {
// MySQL-safe equivalent of the Laravel query in the freescout module.
// Active = placed OR shipped within the last 3 months.
+ // NOW() here is Central (server TZ) — intended: this is a rough
+ // trailing window, not a business-day boundary.
const [ordersRaw] = await connection.execute(
`SELECT order_id, order_status, order_type, summary_total,
date_placed, ship_method_type, ship_method_tracking,
diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js
index 232cb36..2f247ad 100644
--- a/inventory-server/dashboard/acot-server/routes/discounts.js
+++ b/inventory-server/dashboard/acot-server/routes/discounts.js
@@ -1,6 +1,7 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection } from '../db/connection.js';
+import { BUSINESS_TZ, businessNow, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -55,20 +56,20 @@ const DEFAULTS = {
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
};
+// Parse date inputs in BUSINESS time (date-only strings = business dates;
+// zoned instants keep their instant). Never zoneless — that was Berlin.
function parseDate(value, fallback) {
if (!value) {
return fallback;
}
- const parsed = DateTime.fromISO(value);
+ const parsed = DateTime.fromISO(value, { zone: BUSINESS_TZ });
if (!parsed.isValid) {
return fallback;
}
return parsed;
}
-function formatDateForSql(dt) {
- return dt.toFormat('yyyy-LL-dd HH:mm:ss');
-}
+const formatDateForSql = (dt) => toMySqlBound(dt);
router.get('/promos', async (req, res) => {
let connection;
@@ -78,11 +79,12 @@ router.get('/promos', async (req, res) => {
const releaseConnection = release;
const { startDate, endDate } = req.query || {};
- const now = DateTime.now().endOf('day');
+ // Half-open business-day range [start, dayAfterEnd) in Chicago time.
+ const now = businessNow();
const defaultStart = now.minus({ years: 3 }).startOf('day');
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
- const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
+ const parsedEnd = (endDate ? parseDate(endDate, now) : now).startOf('day').plus({ days: 1 });
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
@@ -110,7 +112,7 @@ router.get('/promos', async (req, res) => {
) u ON u.discount_code = p.promo_id
WHERE p.date_start IS NOT NULL
AND p.date_end IS NOT NULL
- AND NOT (p.date_end < ? OR p.date_start > ?)
+ AND NOT (p.date_end < ? OR p.date_start >= ?)
AND p.store = 1
AND p.date_start >= '2010-01-01'
ORDER BY p.promo_id DESC
@@ -343,10 +345,11 @@ router.post('/simulate', async (req, res) => {
pointsConfig = {}
} = req.body || {};
- const endDefault = DateTime.now();
+ // Half-open business-day bounds [startOfDay, dayAfterEnd) in Chicago time.
+ const endDefault = businessNow();
const startDefault = endDefault.minus({ months: 6 });
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
- const endDt = parseDate(dateRange.end, endDefault).endOf('day');
+ const endDt = parseDate(dateRange.end, endDefault).startOf('day').plus({ days: 1 });
const shipCountry = filters.shipCountry || 'US';
const promoIds = Array.from(
@@ -463,7 +466,7 @@ router.post('/simulate', async (req, res) => {
AND o.order_status >= 20
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
- AND o.date_placed BETWEEN ? AND ?
+ AND o.date_placed >= ? AND o.date_placed < ?
${promoExistsClause}
`;
diff --git a/inventory-server/dashboard/acot-server/routes/employee-metrics.js b/inventory-server/dashboard/acot-server/routes/employee-metrics.js
index 00a5040..d714eb4 100644
--- a/inventory-server/dashboard/acot-server/routes/employee-metrics.js
+++ b/inventory-server/dashboard/acot-server/routes/employee-metrics.js
@@ -1,12 +1,25 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
-import { getTimeRangeConditions, _internal as timeHelpers } from '../utils/timeUtils.js';
+import {
+ getTimeRangeConditions,
+ customRange,
+ previousRange,
+ parseMySql,
+ BUSINESS_TZ,
+} from '../../../shared/business-time/index.js';
const router = express.Router();
const TIMEZONE = 'America/New_York';
+// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
+// them as strings (dateStrings: true). Parse explicitly in business time.
+const punchTime = (punch) => {
+ const dt = parseMySql(punch.TimeStamp);
+ return dt ? dt.toJSDate() : new Date(NaN);
+};
+
// Punch types from the database
const PUNCH_TYPES = {
OUT: 0,
@@ -40,7 +53,7 @@ function calculateHoursFromPunches(punches) {
byEmployee.forEach((employeePunches, employeeId) => {
// Sort by timestamp
- employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
+ employeePunches.sort((a, b) => punchTime(a) - punchTime(b));
let hours = 0;
let breakHours = 0;
@@ -48,24 +61,24 @@ function calculateHoursFromPunches(punches) {
let breakStart = null;
employeePunches.forEach(punch => {
- const punchTime = new Date(punch.TimeStamp);
+ const punchAt = punchTime(punch);
switch (punch.PunchType) {
case PUNCH_TYPES.IN:
- currentIn = punchTime;
+ currentIn = punchAt;
break;
case PUNCH_TYPES.OUT:
if (currentIn) {
- hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours
+ hours += (punchAt - currentIn) / (1000 * 60 * 60); // Convert ms to hours
currentIn = null;
}
break;
case PUNCH_TYPES.BREAK_START:
- breakStart = punchTime;
+ breakStart = punchAt;
break;
case PUNCH_TYPES.BREAK_END:
if (breakStart) {
- breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
+ breakHours += (punchAt - breakStart) / (1000 * 60 * 60);
breakStart = null;
}
break;
@@ -315,13 +328,13 @@ router.get('/', async (req, res) => {
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
const weeksInPeriod = periodDays / 7;
- // Get daily trend data for hours
- // Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues
- // Business day starts at 1 AM, so subtract 1 hour before taking the date
+ // Get daily trend data for hours.
+ // TimeStamp stores Central wall-clock literals; business date = the
+ // Central calendar date, so DATE_FORMAT(col) needs no hour shift.
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
const trendQuery = `
SELECT
- DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
+ DATE_FORMAT(tc.TimeStamp, '%Y-%m-%d') as date,
tc.EmployeeID,
tc.TimeStamp,
tc.PunchType
@@ -337,18 +350,19 @@ router.get('/', async (req, res) => {
// Get daily picking data for trend
// Ship-together orders: only count main orders (is_sub = 0 or NULL)
- // Use DATE_FORMAT for consistent date string format
+ // createddate is a Central wall-clock literal — DATE_FORMAT(col) is the
+ // business date with no hour shift.
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
- DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
+ DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
- GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
+ GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
ORDER BY date
`;
@@ -376,13 +390,14 @@ router.get('/', async (req, res) => {
byDate.get(date).push(row);
});
- // Generate all dates in the period range for complete trend data
+ // Generate all business dates in the half-open period [start, end) for
+ // complete trend data. Business date = Chicago calendar date.
const allDatesInRange = [];
- const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
- const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
+ const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
+ const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt;
- while (currentDt <= endDt) {
+ while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 });
}
@@ -393,7 +408,8 @@ router.get('/', async (req, res) => {
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
- // Parse date string in Eastern timezone to get proper ISO timestamp
+ // The string is already the business date; the zone here is only for
+ // the display timestamp label (office time).
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return {
@@ -646,22 +662,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null;
}
- const start = new Date(startDate);
- const end = new Date(endDate);
-
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ try {
+ const prev = previousRange(customRange(startDate, endDate));
+ if (!prev) return null;
+ return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
+ } catch {
return null;
}
-
- const duration = end.getTime() - start.getTime();
- if (!Number.isFinite(duration) || duration <= 0) {
- return null;
- }
-
- const prevEnd = new Date(start.getTime() - 1);
- const prevStart = new Date(prevEnd.getTime() - duration);
-
- return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
}
function getPreviousTimeRange(timeRange) {
diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js
index 17cc170..665a423 100644
--- a/inventory-server/dashboard/acot-server/routes/events.js
+++ b/inventory-server/dashboard/acot-server/routes/events.js
@@ -5,8 +5,12 @@ import {
getTimeRangeConditions,
formatBusinessDate,
getBusinessDayBounds,
+ customRange,
+ previousRange,
+ parseMySql,
+ toMySqlBound,
_internal as timeHelpers,
-} from '../utils/timeUtils.js';
+} from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -83,7 +87,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
- AND ${whereClause.replace('date_placed', 'date_cancelled')}
+ AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
`;
const [cancelledResult] = await connection.execute(cancelledQuery, params);
@@ -96,7 +100,7 @@ router.get('/stats', async (req, res) => {
ABS(SUM(payment_amount)) as refundTotal
FROM order_payment op
JOIN _order o ON op.order_id = o.order_id
- WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
+ WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
`;
const [refundStats] = await connection.execute(refundsQuery, params);
@@ -108,7 +112,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
- AND ${whereClause.replace('date_placed', 'date_shipped')}
+ AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
`;
const [shippedResult] = await connection.execute(shippedQuery, params);
@@ -129,29 +133,29 @@ router.get('/stats', async (req, res) => {
const [bestDayResult] = await connection.execute(bestDayQuery, params);
- // Peak hour query - uses selected time range for the card value
+ // Peak hour query - uses selected time range for the card value.
+ // date_placed stores Central wall-clock; the office reads the axis in
+ // Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = `
SELECT
- HOUR(date_placed) as hour,
+ HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
- GROUP BY HOUR(date_placed)
+ GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
ORDER BY count DESC
LIMIT 1
`;
const [peakHourResult] = await connection.execute(peakHourQuery, params);
if (peakHourResult.length > 0) {
- const hour = peakHourResult[0].hour;
- const date = new Date();
- date.setHours(hour, 0, 0);
+ const hour = parseInt(peakHourResult[0].hour);
peakHour = {
hour,
count: parseInt(peakHourResult[0].count),
- displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
+ displayHour: DateTime.fromObject({ hour }).toFormat('h a')
};
}
}
@@ -160,23 +164,27 @@ router.get('/stats', async (req, res) => {
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
let hourlyOrders = null;
if (['today', 'yesterday'].includes(timeRange)) {
- // Get hourly counts AND current hour from MySQL to avoid timezone mismatch
+ // Hourly counts in EASTERN display hours (column stores Central
+ // wall-clock; ET = CT + 1h year-round). currentHour comes from the same
+ // expression so the rolling window stays aligned.
const hourlyQuery = `
SELECT
- HOUR(date_placed) as hour,
+ HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count,
- HOUR(NOW()) as currentHour
+ HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND date_placed >= NOW() - INTERVAL 24 HOUR
- GROUP BY HOUR(date_placed)
+ GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
`;
const [hourlyResult] = await connection.execute(hourlyQuery);
- // Get current hour from MySQL (same timezone as the WHERE clause)
- const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
+ // Current hour in Eastern (fallback computes it directly)
+ const currentHour = hourlyResult.length > 0
+ ? parseInt(hourlyResult[0].currentHour)
+ : DateTime.now().setZone(TIMEZONE).hour;
// Build map of hour -> count
const hourCounts = {};
@@ -208,7 +216,7 @@ router.get('/stats', async (req, res) => {
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
JOIN product_categories pc ON p.company = pc.cat_id
- WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
+ WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
@@ -233,7 +241,7 @@ router.get('/stats', async (req, res) => {
JOIN product_categories pc ON pci.cat_id = pc.cat_id
WHERE o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
- AND ${whereClause.replace('date_placed', 'o.date_placed')}
+ AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
AND pc.type IN (10, 20, 11, 21, 12, 13)
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
@@ -253,7 +261,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
- AND ${whereClause.replace('date_placed', 'date_shipped')}
+ AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
GROUP BY ship_country, ship_state, ship_method_selected
`;
@@ -425,7 +433,7 @@ router.get('/stats/details', async (req, res) => {
WHERE op.payment_amount < 0
AND o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
- AND ${whereClause.replace('date_placed', 'op.payment_date')}
+ AND ${whereClause.replace(/date_placed/g, 'op.payment_date')}
GROUP BY DATE(op.payment_date)
ORDER BY DATE(op.payment_date)
`;
@@ -457,7 +465,7 @@ router.get('/stats/details', async (req, res) => {
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
- AND ${whereClause.replace('date_placed', 'date_cancelled')}
+ AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
GROUP BY DATE(date_cancelled)
ORDER BY DATE(date_cancelled)
`;
@@ -549,16 +557,10 @@ router.get('/stats/details', async (req, res) => {
prevWhereClause = result.whereClause;
prevParams = result.params;
} else {
- // Custom date range - go back by the same duration
- const start = new Date(startDate);
- const end = new Date(endDate);
- const duration = end.getTime() - start.getTime();
-
- const prevEnd = new Date(start.getTime() - 1);
- const prevStart = new Date(prevEnd.getTime() - duration);
-
- prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
- prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
+ // Custom date range - go back by the same duration (business-day aware)
+ const prev = previousRange(customRange(startDate, endDate));
+ prevWhereClause = 'date_placed >= ? AND date_placed < ?';
+ prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
}
// Get previous period daily data (optionally excludes Cherry Box orders)
@@ -572,14 +574,14 @@ router.get('/stats/details', async (req, res) => {
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter}
GROUP BY DATE(date_placed)
`;
-
+
const [prevResults] = await connection.execute(prevQuery, prevParams);
-
+
// Create a map for quick lookup of previous period data
+ // (dateStrings: true — DATE columns arrive as 'YYYY-MM-DD' strings)
const prevMap = new Map();
prevResults.forEach(prev => {
- const key = new Date(prev.date).toISOString().split('T')[0];
- prevMap.set(key, prev);
+ prevMap.set(String(prev.date), prev);
});
// For period-to-period comparison, we need to map days by relative position
@@ -636,8 +638,8 @@ router.get('/financials', async (req, res) => {
const formatDebugBound = (value) => {
if (!value) return 'n/a';
- const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
- if (!parsed.isValid) {
+ const parsed = parseMySql(value);
+ if (!parsed) {
return `invalid(${value})`;
}
return parsed.setZone(TIMEZONE).toISO();
@@ -771,7 +773,7 @@ router.get('/products', async (req, res) => {
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
- WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
+ WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
GROUP BY p.pid, p.description
ORDER BY totalRevenue DESC
LIMIT 500
@@ -1029,15 +1031,13 @@ function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
}
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
- const businessDayOffset = BUSINESS_DAY_START_HOUR;
+ // date_change stores Central wall-clock literals; business date = the
+ // Central calendar date, so DATE_FORMAT(col) needs no hour shift.
// Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) {
return `
- SELECT
- DATE_FORMAT(
- DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
- '%Y-%m-%d'
- ) as businessDate,
+ SELECT
+ DATE_FORMAT(r.date_change, '%Y-%m-%d') as businessDate,
SUM(r.sale_amount) as grossSales,
SUM(r.refund_amount) as refunds,
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
@@ -1054,11 +1054,8 @@ function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
`;
}
return `
- SELECT
- DATE_FORMAT(
- DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
- '%Y-%m-%d'
- ) as businessDate,
+ SELECT
+ DATE_FORMAT(date_change, '%Y-%m-%d') as businessDate,
SUM(sale_amount) as grossSales,
SUM(refund_amount) as refunds,
SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees,
@@ -1193,22 +1190,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null;
}
- const start = new Date(startDate);
- const end = new Date(endDate);
-
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ try {
+ const prev = previousRange(customRange(startDate, endDate));
+ if (!prev) return null;
+ return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
+ } catch {
return null;
}
-
- const duration = end.getTime() - start.getTime();
- if (!Number.isFinite(duration) || duration <= 0) {
- return null;
- }
-
- const prevEnd = new Date(start.getTime() - 1);
- const prevStart = new Date(prevEnd.getTime() - duration);
-
- return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
}
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
@@ -1221,18 +1209,12 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate,
prevWhereClause = result.whereClause;
prevParams = result.params;
} else {
- // Custom date range - go back by the same duration
- const start = new Date(startDate);
- const end = new Date(endDate);
- const duration = end.getTime() - start.getTime();
-
- const prevEnd = new Date(start.getTime() - 1);
- const prevStart = new Date(prevEnd.getTime() - duration);
-
- prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
- prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
+ // Custom date range - go back by the same duration (business-day aware)
+ const prev = previousRange(customRange(startDate, endDate));
+ prevWhereClause = 'date_placed >= ? AND date_placed < ?';
+ prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
}
-
+
// Previous period query (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT
diff --git a/inventory-server/dashboard/acot-server/routes/operations-metrics.js b/inventory-server/dashboard/acot-server/routes/operations-metrics.js
index c549a93..fae7f6f 100644
--- a/inventory-server/dashboard/acot-server/routes/operations-metrics.js
+++ b/inventory-server/dashboard/acot-server/routes/operations-metrics.js
@@ -1,7 +1,12 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
-import { getTimeRangeConditions } from '../utils/timeUtils.js';
+import {
+ getTimeRangeConditions,
+ customRange,
+ previousRange,
+ BUSINESS_TZ,
+} from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -170,9 +175,10 @@ router.get('/', async (req, res) => {
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
- // Get daily trend data for picking
- // Use DATE_FORMAT to get date string in Eastern timezone
- // Business day starts at 1 AM, so subtract 1 hour before taking the date
+ // Get daily trend data for picking.
+ // Columns store Central wall-clock literals, and the business date IS
+ // the Central calendar date — so DATE(col) is the business date with
+ // no hour shift.
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
@@ -181,22 +187,22 @@ router.get('/', async (req, res) => {
pt_agg.piecesPicked
FROM (
SELECT
- DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
+ DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
- GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
+ GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
) pt_agg
LEFT JOIN (
SELECT
- DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
+ DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
- GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
+ GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
) order_counts ON pt_agg.date = order_counts.date
ORDER BY pt_agg.date
`;
@@ -205,13 +211,13 @@ router.get('/', async (req, res) => {
const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
const shippingTrendQuery = `
SELECT
- DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
+ DATE_FORMAT(o.date_shipped, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
FROM _order o
WHERE ${shippingTrendWhere}
AND o.order_status IN (100, 92)
- GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d')
+ GROUP BY DATE_FORMAT(o.date_shipped, '%Y-%m-%d')
ORDER BY date
`;
@@ -239,13 +245,14 @@ router.get('/', async (req, res) => {
});
});
- // Generate all dates in the period range for complete trend data
+ // Generate all business dates in the half-open period [start, end) for
+ // complete trend data. Business date = Chicago calendar date.
const allDatesInRange = [];
- const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
- const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
+ const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
+ const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt;
- while (currentDt <= endDt) {
+ while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 });
}
@@ -255,7 +262,8 @@ router.get('/', async (req, res) => {
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 0 };
- // Parse date string in Eastern timezone to get proper ISO timestamp
+ // The string is already the business date; the zone here is only for
+ // the display timestamp label (office time).
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return {
@@ -446,22 +454,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null;
}
- const start = new Date(startDate);
- const end = new Date(endDate);
-
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ try {
+ const prev = previousRange(customRange(startDate, endDate));
+ if (!prev) return null;
+ return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
+ } catch {
return null;
}
-
- const duration = end.getTime() - start.getTime();
- if (!Number.isFinite(duration) || duration <= 0) {
- return null;
- }
-
- const prevEnd = new Date(start.getTime() - 1);
- const prevStart = new Date(prevEnd.getTime() - duration);
-
- return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
}
function getPreviousTimeRange(timeRange) {
diff --git a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js
index 834b56e..0dbd6dd 100644
--- a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js
+++ b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js
@@ -1,11 +1,20 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
+import { parseMySql, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router();
const TIMEZONE = 'America/New_York';
+// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
+// them as strings (dateStrings: true). Parse in business time, compare/display
+// in ET (pay-period boundaries are an ET display convention).
+const punchDateTime = (punch) => {
+ const dt = parseMySql(punch.TimeStamp);
+ return dt ? dt.setZone(TIMEZONE) : null;
+};
+
// Punch types from the database
const PUNCH_TYPES = {
OUT: 0,
@@ -108,17 +117,17 @@ function calculateHoursByWeek(punches, payPeriod) {
byEmployee.forEach((employeeData) => {
// Sort punches by timestamp
- employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
+ employeeData.punches.sort((a, b) => (punchDateTime(a)?.toMillis() ?? 0) - (punchDateTime(b)?.toMillis() ?? 0));
// Calculate hours for each week
const week1Punches = employeeData.punches.filter(p => {
- const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
- return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
+ const dt = punchDateTime(p);
+ return dt && dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
});
const week2Punches = employeeData.punches.filter(p => {
- const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
- return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
+ const dt = punchDateTime(p);
+ return dt && dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
});
const week1Hours = calculateHoursFromPunches(week1Punches);
@@ -205,7 +214,7 @@ function calculateHoursFromPunches(punches) {
let breakStart = null;
punches.forEach(punch => {
- const punchTime = new Date(punch.TimeStamp);
+ const punchTime = punchDateTime(punch)?.toJSDate() ?? new Date(NaN);
switch (punch.PunchType) {
case PUNCH_TYPES.IN:
@@ -283,9 +292,11 @@ router.get('/', async (req, res) => {
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
try {
- // Build query for the pay period
- const periodStart = payPeriod.start.toJSDate();
- const periodEnd = payPeriod.end.toJSDate();
+ // Build query for the pay period. Bounds are serialized to Central
+ // wall-clock strings (the column's literal zone), half-open
+ // [start, start + 14 days).
+ const periodStart = toMySqlBound(payPeriod.start);
+ const periodEnd = toMySqlBound(payPeriod.start.plus({ days: 14 }));
const timeclockQuery = `
SELECT
@@ -296,7 +307,7 @@ router.get('/', async (req, res) => {
e.lastname
FROM timeclock tc
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
- WHERE tc.TimeStamp >= ? AND tc.TimeStamp <= ?
+ WHERE tc.TimeStamp >= ? AND tc.TimeStamp < ?
AND e.hidden = 0
AND e.disabled = 0
ORDER BY tc.EmployeeID, tc.TimeStamp
@@ -322,8 +333,8 @@ router.get('/', async (req, res) => {
// Get previous pay period data for comparison
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
- prevPayPeriod.start.toJSDate(),
- prevPayPeriod.end.toJSDate(),
+ toMySqlBound(prevPayPeriod.start),
+ toMySqlBound(prevPayPeriod.start.plus({ days: 14 })),
]);
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
diff --git a/inventory-server/dashboard/acot-server/utils/timeUtils.js b/inventory-server/dashboard/acot-server/utils/timeUtils.js
deleted file mode 100644
index 0d34bb5..0000000
--- a/inventory-server/dashboard/acot-server/utils/timeUtils.js
+++ /dev/null
@@ -1,317 +0,0 @@
-import { DateTime } from 'luxon';
-
-const TIMEZONE = 'America/New_York';
-const DB_TIMEZONE = 'UTC-05:00';
-const BUSINESS_DAY_START_HOUR = 1; // 1 AM Eastern
-const WEEK_START_DAY = 7; // Sunday (Luxon uses 1 = Monday, 7 = Sunday)
-const DB_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
-
-const isDateTime = (value) => DateTime.isDateTime(value);
-
-const ensureDateTime = (value, { zone = TIMEZONE } = {}) => {
- if (!value) return null;
-
- if (isDateTime(value)) {
- return value.setZone(zone);
- }
-
- if (value instanceof Date) {
- return DateTime.fromJSDate(value, { zone });
- }
-
- if (typeof value === 'number') {
- return DateTime.fromMillis(value, { zone });
- }
-
- if (typeof value === 'string') {
- let dt = DateTime.fromISO(value, { zone, setZone: true });
- if (!dt.isValid) {
- dt = DateTime.fromSQL(value, { zone });
- }
- return dt.isValid ? dt : null;
- }
-
- return null;
-};
-
-const getNow = () => DateTime.now().setZone(TIMEZONE);
-
-const getDayStart = (input = getNow()) => {
- const dt = ensureDateTime(input);
- if (!dt || !dt.isValid) {
- const fallback = getNow();
- return fallback.set({
- hour: BUSINESS_DAY_START_HOUR,
- minute: 0,
- second: 0,
- millisecond: 0
- });
- }
-
- const sameDayStart = dt.set({
- hour: BUSINESS_DAY_START_HOUR,
- minute: 0,
- second: 0,
- millisecond: 0
- });
-
- return dt.hour < BUSINESS_DAY_START_HOUR
- ? sameDayStart.minus({ days: 1 })
- : sameDayStart;
-};
-
-const getDayEnd = (input = getNow()) => {
- return getDayStart(input).plus({ days: 1 }).minus({ milliseconds: 1 });
-};
-
-const getWeekStart = (input = getNow()) => {
- const dt = ensureDateTime(input);
- if (!dt || !dt.isValid) {
- return getDayStart();
- }
-
- const startOfWeek = dt.set({ weekday: WEEK_START_DAY }).startOf('day');
- const normalized = startOfWeek > dt ? startOfWeek.minus({ weeks: 1 }) : startOfWeek;
- return normalized.set({
- hour: BUSINESS_DAY_START_HOUR,
- minute: 0,
- second: 0,
- millisecond: 0
- });
-};
-
-const getRangeForTimeRange = (timeRange = 'today', now = getNow()) => {
- const current = ensureDateTime(now);
- if (!current || !current.isValid) {
- throw new Error('Invalid reference time for range calculation');
- }
-
- switch (timeRange) {
- case 'today': {
- return {
- start: getDayStart(current),
- end: getDayEnd(current)
- };
- }
- case 'yesterday': {
- const target = current.minus({ days: 1 });
- return {
- start: getDayStart(target),
- end: getDayEnd(target)
- };
- }
- case 'twoDaysAgo': {
- const target = current.minus({ days: 2 });
- return {
- start: getDayStart(target),
- end: getDayEnd(target)
- };
- }
- case 'thisWeek': {
- return {
- start: getWeekStart(current),
- end: getDayEnd(current)
- };
- }
- case 'lastWeek': {
- const lastWeek = current.minus({ weeks: 1 });
- const weekStart = getWeekStart(lastWeek);
- const weekEnd = weekStart.plus({ days: 6 });
- return {
- start: weekStart,
- end: getDayEnd(weekEnd)
- };
- }
- case 'thisMonth': {
- const dayStart = getDayStart(current);
- const monthStart = dayStart.startOf('month').set({ hour: BUSINESS_DAY_START_HOUR });
- return {
- start: monthStart,
- end: getDayEnd(current)
- };
- }
- case 'lastMonth': {
- const lastMonth = current.minus({ months: 1 });
- const monthStart = lastMonth
- .startOf('month')
- .set({ hour: BUSINESS_DAY_START_HOUR, minute: 0, second: 0, millisecond: 0 });
- const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
- return {
- start: monthStart,
- end: getDayEnd(monthEnd)
- };
- }
- case 'last7days': {
- const dayStart = getDayStart(current);
- return {
- start: dayStart.minus({ days: 6 }),
- end: getDayEnd(current)
- };
- }
- case 'last30days': {
- const dayStart = getDayStart(current);
- return {
- start: dayStart.minus({ days: 29 }),
- end: getDayEnd(current)
- };
- }
- case 'last90days': {
- const dayStart = getDayStart(current);
- return {
- start: dayStart.minus({ days: 89 }),
- end: getDayEnd(current)
- };
- }
- case 'previous7days': {
- const currentPeriodStart = getDayStart(current).minus({ days: 6 });
- const previousEndDay = currentPeriodStart.minus({ days: 1 });
- const previousStartDay = previousEndDay.minus({ days: 6 });
- return {
- start: getDayStart(previousStartDay),
- end: getDayEnd(previousEndDay)
- };
- }
- case 'previous30days': {
- const currentPeriodStart = getDayStart(current).minus({ days: 29 });
- const previousEndDay = currentPeriodStart.minus({ days: 1 });
- const previousStartDay = previousEndDay.minus({ days: 29 });
- return {
- start: getDayStart(previousStartDay),
- end: getDayEnd(previousEndDay)
- };
- }
- case 'previous90days': {
- const currentPeriodStart = getDayStart(current).minus({ days: 89 });
- const previousEndDay = currentPeriodStart.minus({ days: 1 });
- const previousStartDay = previousEndDay.minus({ days: 89 });
- return {
- start: getDayStart(previousStartDay),
- end: getDayEnd(previousEndDay)
- };
- }
- default:
- throw new Error(`Unknown time range: ${timeRange}`);
- }
-};
-
-const toDatabaseSqlString = (dt) => {
- const normalized = ensureDateTime(dt);
- if (!normalized || !normalized.isValid) {
- throw new Error('Invalid datetime provided for SQL conversion');
- }
- const dbTime = normalized.setZone(DB_TIMEZONE, { keepLocalTime: true });
- return dbTime.toFormat(DB_DATETIME_FORMAT);
-};
-
-const formatBusinessDate = (input) => {
- const dt = ensureDateTime(input);
- if (!dt || !dt.isValid) return '';
- return dt.setZone(TIMEZONE).toFormat('LLL d, yyyy');
-};
-
-const getTimeRangeLabel = (timeRange) => {
- const labels = {
- today: 'Today',
- yesterday: 'Yesterday',
- twoDaysAgo: 'Two Days Ago',
- thisWeek: 'This Week',
- lastWeek: 'Last Week',
- thisMonth: 'This Month',
- lastMonth: 'Last Month',
- last7days: 'Last 7 Days',
- last30days: 'Last 30 Days',
- last90days: 'Last 90 Days',
- previous7days: 'Previous 7 Days',
- previous30days: 'Previous 30 Days',
- previous90days: 'Previous 90 Days'
- };
-
- return labels[timeRange] || timeRange;
-};
-
-const getTimeRangeConditions = (timeRange, startDate, endDate) => {
- if (timeRange === 'custom' && startDate && endDate) {
- const start = ensureDateTime(startDate);
- const end = ensureDateTime(endDate);
-
- if (!start || !start.isValid || !end || !end.isValid) {
- throw new Error('Invalid custom date range provided');
- }
-
- return {
- whereClause: 'date_placed >= ? AND date_placed <= ?',
- params: [toDatabaseSqlString(start), toDatabaseSqlString(end)],
- dateRange: {
- start: start.toUTC().toISO(),
- end: end.toUTC().toISO(),
- label: `${formatBusinessDate(start)} - ${formatBusinessDate(end)}`
- }
- };
- }
-
- const normalizedRange = timeRange || 'today';
- const range = getRangeForTimeRange(normalizedRange);
-
- return {
- whereClause: 'date_placed >= ? AND date_placed <= ?',
- params: [toDatabaseSqlString(range.start), toDatabaseSqlString(range.end)],
- dateRange: {
- start: range.start.toUTC().toISO(),
- end: range.end.toUTC().toISO(),
- label: getTimeRangeLabel(normalizedRange)
- }
- };
-};
-
-const getBusinessDayBounds = (timeRange) => {
- const range = getRangeForTimeRange(timeRange);
- return {
- start: range.start.toJSDate(),
- end: range.end.toJSDate()
- };
-};
-
-const parseBusinessDate = (mysqlDatetime) => {
- if (!mysqlDatetime || mysqlDatetime === '0000-00-00 00:00:00') {
- return null;
- }
-
- const dt = DateTime.fromSQL(mysqlDatetime, { zone: DB_TIMEZONE });
- if (!dt.isValid) {
- console.error('[timeUtils] Failed to parse MySQL datetime:', mysqlDatetime, dt.invalidExplanation);
- return null;
- }
-
- return dt.toUTC().toJSDate();
-};
-
-const formatMySQLDate = (input) => {
- if (!input) return null;
-
- const dt = ensureDateTime(input, { zone: 'utc' });
- if (!dt || !dt.isValid) return null;
-
- return dt.setZone(DB_TIMEZONE).toFormat(DB_DATETIME_FORMAT);
-};
-
-// Expose helpers for tests or advanced consumers.
-// Kept as a named `_internal` export so existing destructuring sites
-// (`const { _internal: timeHelpers } = require(...)` → ESM equivalent works)
-// don't need to change beyond the import-statement rewrite.
-const _internal = {
- getDayStart,
- getDayEnd,
- getWeekStart,
- getRangeForTimeRange,
- BUSINESS_DAY_START_HOUR,
-};
-
-export {
- getBusinessDayBounds,
- getTimeRangeConditions,
- formatBusinessDate,
- getTimeRangeLabel,
- parseBusinessDate,
- formatMySQLDate,
- _internal,
-};
diff --git a/inventory-server/dashboard/routes/klaviyo/campaigns.routes.js b/inventory-server/dashboard/routes/klaviyo/campaigns.routes.js
index f6b1598..937e5e4 100644
--- a/inventory-server/dashboard/routes/klaviyo/campaigns.routes.js
+++ b/inventory-server/dashboard/routes/klaviyo/campaigns.routes.js
@@ -1,6 +1,6 @@
import express from 'express';
import { CampaignsService } from '../../services/klaviyo/campaigns.service.js';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
export function createCampaignsRouter(apiKey, apiRevision, redis) {
const router = express.Router();
diff --git a/inventory-server/dashboard/routes/klaviyo/events.routes.js b/inventory-server/dashboard/routes/klaviyo/events.routes.js
index bbad0bd..f69b74c 100644
--- a/inventory-server/dashboard/routes/klaviyo/events.routes.js
+++ b/inventory-server/dashboard/routes/klaviyo/events.routes.js
@@ -1,6 +1,6 @@
import express from 'express';
import { EventsService } from '../../services/klaviyo/events.service.js';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from '../../services/klaviyo/redis.service.js';
import { requirePermission } from '../../../shared/auth/middleware.js';
diff --git a/inventory-server/dashboard/routes/klaviyo/reporting.routes.js b/inventory-server/dashboard/routes/klaviyo/reporting.routes.js
index 363583b..e34acad 100644
--- a/inventory-server/dashboard/routes/klaviyo/reporting.routes.js
+++ b/inventory-server/dashboard/routes/klaviyo/reporting.routes.js
@@ -1,6 +1,6 @@
import express from 'express';
import { ReportingService } from '../../services/klaviyo/reporting.service.js';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
export function createReportingRouter(apiKey, apiRevision, redis) {
const router = express.Router();
diff --git a/inventory-server/dashboard/services/klaviyo/campaigns.service.js b/inventory-server/dashboard/services/klaviyo/campaigns.service.js
index 17b6eda..588b0af 100644
--- a/inventory-server/dashboard/services/klaviyo/campaigns.service.js
+++ b/inventory-server/dashboard/services/klaviyo/campaigns.service.js
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
export class CampaignsService {
diff --git a/inventory-server/dashboard/services/klaviyo/events.service.js b/inventory-server/dashboard/services/klaviyo/events.service.js
index c6a1595..8c07b7b 100644
--- a/inventory-server/dashboard/services/klaviyo/events.service.js
+++ b/inventory-server/dashboard/services/klaviyo/events.service.js
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
import _ from 'lodash';
diff --git a/inventory-server/dashboard/services/klaviyo/redis.service.js b/inventory-server/dashboard/services/klaviyo/redis.service.js
index 588a3d6..84dd91f 100644
--- a/inventory-server/dashboard/services/klaviyo/redis.service.js
+++ b/inventory-server/dashboard/services/klaviyo/redis.service.js
@@ -14,7 +14,7 @@
// Reads short-circuit to null when the client isn't ready; writes are no-ops.
// Same "Redis hiccup → fall through to upstream" behavior as before.
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
export class RedisService {
constructor(redis) {
diff --git a/inventory-server/dashboard/services/klaviyo/reporting.service.js b/inventory-server/dashboard/services/klaviyo/reporting.service.js
index 8ba6b84..84bea10 100644
--- a/inventory-server/dashboard/services/klaviyo/reporting.service.js
+++ b/inventory-server/dashboard/services/klaviyo/reporting.service.js
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
-import { TimeManager } from '../../utils/time.utils.js';
+import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
const METRIC_IDS = {
diff --git a/inventory-server/dashboard/utils/time.utils.js b/inventory-server/dashboard/utils/time.utils.js
deleted file mode 100644
index ab86c02..0000000
--- a/inventory-server/dashboard/utils/time.utils.js
+++ /dev/null
@@ -1,448 +0,0 @@
-import { DateTime } from 'luxon';
-
-export class TimeManager {
- constructor(dayStartHour = 1) {
- this.timezone = 'America/New_York';
- this.dayStartHour = dayStartHour; // Hour (0-23) when the business day starts
- this.weekStartDay = 7; // 7 = Sunday in Luxon
- }
-
- /**
- * Get the start of the current business day
- * If current time is before dayStartHour, return previous day at dayStartHour
- */
- getDayStart(dt = this.getNow()) {
- if (!dt.isValid) {
- console.error("[TimeManager] Invalid datetime provided to getDayStart");
- return this.getNow();
- }
- const dayStart = dt.set({ hour: this.dayStartHour, minute: 0, second: 0, millisecond: 0 });
- return dt.hour < this.dayStartHour ? dayStart.minus({ days: 1 }) : dayStart;
- }
-
- /**
- * Get the end of the current business day
- * End is defined as dayStartHour - 1 minute on the next day
- */
- getDayEnd(dt = this.getNow()) {
- if (!dt.isValid) {
- console.error("[TimeManager] Invalid datetime provided to getDayEnd");
- return this.getNow();
- }
- const nextDay = this.getDayStart(dt).plus({ days: 1 });
- return nextDay.minus({ minutes: 1 });
- }
-
- /**
- * Get the start of the week containing the given date
- * Aligns with custom day start time and starts on Sunday
- */
- getWeekStart(dt = this.getNow()) {
- if (!dt.isValid) {
- console.error("[TimeManager] Invalid datetime provided to getWeekStart");
- return this.getNow();
- }
- // Set to start of week (Sunday) and adjust hour
- const weekStart = dt.set({ weekday: this.weekStartDay }).startOf('day');
- // If the week start time would be after the given time, go back a week
- if (weekStart > dt) {
- return weekStart.minus({ weeks: 1 }).set({ hour: this.dayStartHour });
- }
- return weekStart.set({ hour: this.dayStartHour });
- }
-
- /**
- * Convert any date input to a Luxon DateTime in Eastern time
- */
- toDateTime(date) {
- if (!date) return null;
-
- if (date instanceof DateTime) {
- return date.setZone(this.timezone);
- }
-
- // If it's an ISO string or Date object, parse it
- const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
- if (!dt.isValid) {
- console.error("[TimeManager] Invalid date input:", date);
- return null;
- }
-
- return dt.setZone(this.timezone);
- }
-
- /**
- * Format a date for API requests (UTC ISO string)
- */
- formatForAPI(date) {
- if (!date) return null;
-
- // Parse the input date
- const dt = this.toDateTime(date);
- if (!dt || !dt.isValid) {
- console.error("[TimeManager] Invalid date for API:", date);
- return null;
- }
-
- // Convert to UTC for API request
- const utc = dt.toUTC();
-
- console.log("[TimeManager] API date conversion:", {
- input: date,
- eastern: dt.toISO(),
- utc: utc.toISO(),
- offset: dt.offset
- });
-
- return utc.toISO();
- }
-
- /**
- * Format a date for display (in Eastern time)
- */
- formatForDisplay(date) {
- const dt = this.toDateTime(date);
- if (!dt || !dt.isValid) return '';
- return dt.toFormat('LLL d, yyyy h:mm a');
- }
-
- /**
- * Validate if a date range is valid
- */
- isValidDateRange(start, end) {
- const startDt = this.toDateTime(start);
- const endDt = this.toDateTime(end);
- return startDt && endDt && endDt > startDt;
- }
-
- /**
- * Get the current time in Eastern timezone
- */
- getNow() {
- return DateTime.now().setZone(this.timezone);
- }
-
- /**
- * Get a date range for the last N hours
- */
- getLastNHours(hours) {
- const now = this.getNow();
- return {
- start: now.minus({ hours }),
- end: now
- };
- }
-
- /**
- * Get a date range for the last N days
- * Aligns with custom day start time
- */
- getLastNDays(days) {
- const now = this.getNow();
- const dayStart = this.getDayStart(now);
- return {
- start: dayStart.minus({ days }),
- end: this.getDayEnd(now)
- };
- }
-
- /**
- * Get a date range for a specific time period
- * All ranges align with custom day start time
- */
- getDateRange(period) {
- const now = this.getNow();
-
- // Normalize period to handle both 'last' and 'previous' prefixes
- const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
-
- switch (normalizedPeriod) {
- case 'custom': {
- // Custom ranges are handled separately via getCustomRange
- console.warn('[TimeManager] Custom ranges should use getCustomRange method');
- return null;
- }
- case 'today': {
- const dayStart = this.getDayStart(now);
- return {
- start: dayStart,
- end: this.getDayEnd(now)
- };
- }
- case 'yesterday': {
- const yesterday = now.minus({ days: 1 });
- return {
- start: this.getDayStart(yesterday),
- end: this.getDayEnd(yesterday)
- };
- }
- case 'last7days': {
- // For last 7 days, we want to include today and the previous 6 days
- const dayStart = this.getDayStart(now);
- const weekStart = dayStart.minus({ days: 6 });
- return {
- start: weekStart,
- end: this.getDayEnd(now)
- };
- }
- case 'last30days': {
- // Include today and previous 29 days
- const dayStart = this.getDayStart(now);
- const monthStart = dayStart.minus({ days: 29 });
- return {
- start: monthStart,
- end: this.getDayEnd(now)
- };
- }
- case 'last90days': {
- // Include today and previous 89 days
- const dayStart = this.getDayStart(now);
- const start = dayStart.minus({ days: 89 });
- return {
- start,
- end: this.getDayEnd(now)
- };
- }
- case 'thisWeek': {
- // Get the start of the week (Sunday) with custom hour
- const weekStart = this.getWeekStart(now);
- return {
- start: weekStart,
- end: this.getDayEnd(now)
- };
- }
- case 'lastWeek': {
- const lastWeek = now.minus({ weeks: 1 });
- const weekStart = this.getWeekStart(lastWeek);
- const weekEnd = weekStart.plus({ days: 6 }); // 6 days after start = Saturday
- return {
- start: weekStart,
- end: this.getDayEnd(weekEnd)
- };
- }
- case 'thisMonth': {
- const dayStart = this.getDayStart(now);
- const monthStart = dayStart.startOf('month').set({ hour: this.dayStartHour });
- return {
- start: monthStart,
- end: this.getDayEnd(now)
- };
- }
- case 'lastMonth': {
- const lastMonth = now.minus({ months: 1 });
- const monthStart = lastMonth.startOf('month').set({ hour: this.dayStartHour });
- const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
- return {
- start: monthStart,
- end: this.getDayEnd(monthEnd)
- };
- }
- default:
- console.warn(`[TimeManager] Unknown period: ${period}`);
- return null;
- }
- }
-
- /**
- * Format a duration in milliseconds to a human-readable string
- */
- formatDuration(ms) {
- return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
- }
-
- /**
- * Get relative time string (e.g., "2 hours ago")
- */
- getRelativeTime(date) {
- const dt = this.toDateTime(date);
- if (!dt) return '';
- return dt.toRelative();
- }
-
- /**
- * Get a custom date range using exact dates and times provided
- * @param {string} startDate - ISO string or Date for range start
- * @param {string} endDate - ISO string or Date for range end
- * @returns {Object} Object with start and end DateTime objects
- */
- getCustomRange(startDate, endDate) {
- if (!startDate || !endDate) {
- console.error("[TimeManager] Custom range requires both start and end dates");
- return null;
- }
-
- const start = this.toDateTime(startDate);
- const end = this.toDateTime(endDate);
-
- if (!start || !end || !start.isValid || !end.isValid) {
- console.error("[TimeManager] Invalid dates provided for custom range");
- return null;
- }
-
- // Validate the range
- if (end < start) {
- console.error("[TimeManager] End date must be after start date");
- return null;
- }
-
- return {
- start,
- end
- };
- }
-
- /**
- * Get the previous period's date range based on the current period
- * @param {string} period - The current period
- * @param {DateTime} now - The current datetime (optional)
- * @returns {Object} Object with start and end DateTime objects
- */
- getPreviousPeriod(period, now = this.getNow()) {
- const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
-
- switch (normalizedPeriod) {
- case 'today': {
- const yesterday = now.minus({ days: 1 });
- return {
- start: this.getDayStart(yesterday),
- end: this.getDayEnd(yesterday)
- };
- }
- case 'yesterday': {
- const twoDaysAgo = now.minus({ days: 2 });
- return {
- start: this.getDayStart(twoDaysAgo),
- end: this.getDayEnd(twoDaysAgo)
- };
- }
- case 'last7days': {
- const dayStart = this.getDayStart(now);
- const currentStart = dayStart.minus({ days: 6 });
- const prevEnd = currentStart.minus({ milliseconds: 1 });
- const prevStart = prevEnd.minus({ days: 6 });
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'last30days': {
- const dayStart = this.getDayStart(now);
- const currentStart = dayStart.minus({ days: 29 });
- const prevEnd = currentStart.minus({ milliseconds: 1 });
- const prevStart = prevEnd.minus({ days: 29 });
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'last90days': {
- const dayStart = this.getDayStart(now);
- const currentStart = dayStart.minus({ days: 89 });
- const prevEnd = currentStart.minus({ milliseconds: 1 });
- const prevStart = prevEnd.minus({ days: 89 });
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'thisWeek': {
- const weekStart = this.getWeekStart(now);
- const prevEnd = weekStart.minus({ milliseconds: 1 });
- const prevStart = this.getWeekStart(prevEnd);
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'lastWeek': {
- const lastWeekStart = this.getWeekStart(now.minus({ weeks: 1 }));
- const prevEnd = lastWeekStart.minus({ milliseconds: 1 });
- const prevStart = this.getWeekStart(prevEnd);
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'thisMonth': {
- const monthStart = now.startOf('month').set({ hour: this.dayStartHour });
- const prevEnd = monthStart.minus({ milliseconds: 1 });
- const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- case 'lastMonth': {
- const lastMonthStart = now.minus({ months: 1 }).startOf('month').set({ hour: this.dayStartHour });
- const prevEnd = lastMonthStart.minus({ milliseconds: 1 });
- const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
- return {
- start: prevStart,
- end: prevEnd
- };
- }
- default:
- console.warn(`[TimeManager] No previous period defined for: ${period}`);
- return null;
- }
- }
-
- groupEventsByInterval(events, interval = 'day', property = null) {
- if (!events?.length) return [];
-
- const groupedData = new Map();
- const now = DateTime.now().setZone('America/New_York');
-
- for (const event of events) {
- const datetime = DateTime.fromISO(event.attributes.datetime);
- let groupKey;
-
- switch (interval) {
- case 'hour':
- groupKey = datetime.startOf('hour').toISO();
- break;
- case 'day':
- groupKey = datetime.startOf('day').toISO();
- break;
- case 'week':
- groupKey = datetime.startOf('week').toISO();
- break;
- case 'month':
- groupKey = datetime.startOf('month').toISO();
- break;
- default:
- groupKey = datetime.startOf('day').toISO();
- }
-
- const existingGroup = groupedData.get(groupKey) || {
- datetime: groupKey,
- count: 0,
- value: 0
- };
-
- existingGroup.count++;
-
- if (property) {
- // Extract property value from event
- const props = event.attributes?.event_properties || event.attributes?.properties || {};
- let value = 0;
-
- if (property === '$value') {
- // Special case for $value - use event value
- value = Number(event.attributes?.value || 0);
- } else {
- // Otherwise get from properties
- value = Number(props[property] || 0);
- }
-
- existingGroup.value = (existingGroup.value || 0) + value;
- }
-
- groupedData.set(groupKey, existingGroup);
- }
-
- // Convert to array and sort by datetime
- return Array.from(groupedData.values())
- .sort((a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime));
- }
-}
\ No newline at end of file
diff --git a/inventory-server/docs/TIME.md b/inventory-server/docs/TIME.md
new file mode 100644
index 0000000..3258dc5
--- /dev/null
+++ b/inventory-server/docs/TIME.md
@@ -0,0 +1,67 @@
+# 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).
diff --git a/inventory-server/package-lock.json b/inventory-server/package-lock.json
index c2ce164..6f9fce3 100644
--- a/inventory-server/package-lock.json
+++ b/inventory-server/package-lock.json
@@ -21,6 +21,7 @@
"express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
+ "luxon": "^3.7.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^6.0.0",
@@ -3658,6 +3659,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
diff --git a/inventory-server/package.json b/inventory-server/package.json
index 04c1d7f..c7d1962 100644
--- a/inventory-server/package.json
+++ b/inventory-server/package.json
@@ -32,6 +32,7 @@
"express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
+ "luxon": "^3.7.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^6.0.0",
diff --git a/inventory-server/scripts/calculate-metrics-new.js b/inventory-server/scripts/calculate-metrics-new.js
index 02cd314..80d42fc 100644
--- a/inventory-server/scripts/calculate-metrics-new.js
+++ b/inventory-server/scripts/calculate-metrics-new.js
@@ -140,6 +140,12 @@ console.log('DB Connection Info:', {
password: (dbConfig.password || dbConfig.connectionString) ? '****' : 'MISSING' // Only show if credentials exist
});
+// Pin the session timezone to business time (see docs/TIME.md) — window
+// anchors (CURRENT_DATE etc.) in the metrics SQL must mean Chicago dates.
+if (!dbConfig.options) {
+ dbConfig.options = '-c TimeZone=America/Chicago';
+}
+
const pool = new Pool(dbConfig);
const getConnection = () => {
diff --git a/inventory-server/scripts/forecast/__pycache__/forecast_engine.cpython-312.pyc b/inventory-server/scripts/forecast/__pycache__/forecast_engine.cpython-312.pyc
index ba931b43c993db08d8e8bd12b9367325119420ee..86dd98fb1c179164f7211065eed9f9012a91d573 100644
GIT binary patch
delta 9637
zcma)C33OD|`JbCvlG&0OvXT9fkY&iiz9)nb5(rCxgmu7n7_3OE
zbpdX1gaV4SZiLOz69ojRifBDmLF?bwT9?!Qt@ZqEE8V14|KE3CW)e7UkB^hz-1pt@
zuHSdR`_l|ka_0=@w4@eUcIu8MXx0C%L4uUboRiYVi`H+)j|0qM
z<6cWE{Pz6SI3d4%bOSNjT_C2o1<~OiCpz7Q;uv?4nCc!crn!s7bax5-mcn0|nBjH-
z?*ynT7c<=z;#hYj@K(VN8#})Ja&AmvR?p`d;fchqJrhAE5XV!`H#aS@&QgJp=
zmx*%#my2@&o5eb@8fsREHSo4lS|!fgQl`_KZ-Q$3-zVcbxG9k^G>Yq0BjGbCMqdCH0T?>Rk$nocXcKE;GyYkkCetfyd`O%Rp%l-
zzXJN|4oTSDBm0ArEDNGnk%Y40?gXDVAO%G))yN<3_>mw>k|2hB^2F6m%Nwe?L|2u?
zBEZuu-6GL=Hy$MlN$xcdf3stgb88@;43T)CLSpZ@iRF
z?NGXwR2}ekZ5F-rXb!xVvp*V3j2cgcF))Ru0+FU6WC6%%MFifEvva7xS{ydsYzA-k
zgn_0bH3K0NK-K$Ui~eIjmCT)3YT(m@p(koX7hvQ6*rW<;$069FLeggmB%Rr;1ug+8
zR!&C1b*jnjlH&1&fU*(0e^yAyEoYFI4uh
zMPnwwSai$=1DU{H&!{BD>{`aMs*O;on!MB-Y?o9$Xfz#*6r-0?Z?CHF@@@z42WbjW
zsh264F0zIl&YWjOi$gsKH?vPN=j8BlQ`-EXX?$#74=VN@z|a@19($*W1lYy=mH`n7
zMq!;K*3b!Bd38rm4UihCzlIiTq*kv|^QmdL+!L&E+4;&<=fV~1Jj+(BSh}FDeyL|+
z-G)}xD0Ou!y)*>ObPa%-xXnw0{$RUmmE8+{Fd-0Va;MD1l5CjCAG~I`Q
zQ&tU~A%75dMw>X2??*D}q7UG2QV`@1Putm6jME
z1H!w}hfCMxkfZF{f_xKBmv*uI`mAY#$ZV&f5GXPj!uAlT<1wTq$6h@FFmwSrS~IrP
zPdA;!K2EVm>(`{UijS{fE=3+FO2dAqy8
z%+fmGa;2$B?I5#mkJv_WI(&S397`wI)`d-uQ9~o~cJ|uBn_y{W4Gm;lG}y47Otm2$
zCE?N!H-xfk==O^;y`qtEQ6qIU5dE_8V={gSsm~(ZFvxRAJB@ILeY1GuVpKvdAWdb*
z)URn-+u&K;)Vg{_%LcBq^k>MzrOsu&1YTA1La*X&q+W1ZToul;hnp&LMuh(|G8sg_
zPf_)K{%(4necCiGV-@z)!mIpp(95@pu4clLG$*QsYL>xE$sT!YfZopD3$xCw_Z(!A%2yX$%0{eQez;?f2
zfW7|C(p=+TfoO{kER`)Lbkp=0`^TE~_Eadk60|7_?54GOCOcNy`|lZKUrZs0cS~
zoe>l%(92DPCLO-Zc*0!fkt3ugb<>d8<5Oh%33l>(gi(8juU`VlW`LM2n9oghx@r%E
zyrL)Gfgaq|$878RZ@}(u-QY+db?kc^7nu0*Gqcw=R%YY+;rIl+vg~i`^>lZ5Asm1p
zSC_$@n|=<>-rw}DK5-DZn(1@VGdD|Q(GALX2I?s85Y!m8gOd<@_+tBJ!aq+cKrXQ$dWgRK=k
znwn4u%SZJH=FhA6I_Onkyl&ex50|H=bKi*W!ULg3Z2xF3^fHru?7K>A?3*8EMgJ&P
z>&ww)&>tdn0k}+>okS-_HzD9Dp<5BU+1if6d=qq{+92lf_l1HG1NF+Ik1xBwV*-h=
z3mp}pxSw}q8B2iO&usojVDC@)XBg-4@o-D78x+S=jeT%ZhusMuks=%R05&-verN$J
z4ppWLSiC`OMXWtEFOQ4g84s_zsT86jxT%TI;!gIn&`UWe8y^lIdr<9oyehII-D}BD
zqyOmML|6yCiKMdgX?ZMpyxBnOl~i_-mM~{|UUaHlm}D~J6pltWe)kn(nvCTstgOE@
zK}Vvi`u7mWR3v>LdZsRf0Q=(BWyBt>9k`E>Y3z+1Ge~mOblW$COlPLsXA(sq3&K+ns!nXU{J7yU@%pCY}Y*8gfGWt=mtj)t!jl>
z#(#@9fL2h|d&M@@$U|_7wn6&5Xy>1Jnl)q;9_wJXok
z4C9^#ama5+i@xt9hWslIAv*U5FX};EzWHIIae(`LZu0n+^S$QUGX=L8 WY*(-06
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 date:
+ """The business date (Chicago calendar date — see docs/TIME.md).
+
+ Never business_today(): the server runs on Europe/Berlin, which flips to the
+ next day at 5/6pm Central and would shift forecast_date anchors.
+ """
+ return datetime.now(BUSINESS_TZ).date()
import numpy as np
import pandas as pd
@@ -740,7 +752,7 @@ def batch_load_product_data(conn, products):
GROUP BY pid
"""
adf = execute_query(conn, arrival_sql, [preorder_pids])
- today = date.today()
+ today = business_today()
for _, row in adf.iterrows():
pid = int(row['pid'])
fa = row['future_arrival']
@@ -948,7 +960,7 @@ def forecast_mature(product, history_df):
hist['snapshot_date'] = pd.to_datetime(hist['snapshot_date'])
hist = hist.set_index('snapshot_date')['units_sold']
full_index = pd.date_range(
- end=pd.Timestamp(date.today() - timedelta(days=1)),
+ end=pd.Timestamp(business_today() - timedelta(days=1)),
periods=EXP_SMOOTHING_WINDOW, freq='D')
series = hist.reindex(full_index, fill_value=0.0).values.astype(float)
@@ -1070,7 +1082,7 @@ def generate_all_forecasts(conn, curves_df, dow_indices, monthly_indices=None,
log.info("Batch loading product data...")
batch_data = batch_load_product_data(conn, products)
- today = date.today()
+ today = business_today()
forecast_dates = [today + timedelta(days=i) for i in range(FORECAST_HORIZON_DAYS)]
# Pre-compute DOW and seasonal multipliers for each forecast date.
@@ -1676,7 +1688,7 @@ def backfill_accuracy_data(conn, backfill_days=30):
# Batch load product data for per-product scaling
batch_data = batch_load_product_data(conn, active)
- today = date.today()
+ today = business_today()
backfill_start = today - timedelta(days=backfill_days)
# Create a synthetic run entry
diff --git a/inventory-server/scripts/forecast/run_forecast.js b/inventory-server/scripts/forecast/run_forecast.js
index 625edc2..e23acc3 100644
--- a/inventory-server/scripts/forecast/run_forecast.js
+++ b/inventory-server/scripts/forecast/run_forecast.js
@@ -14,6 +14,10 @@
* /var/www/inventory/.env (or current process env).
*/
+// Run in business time (see docs/TIME.md); the spawned Python engine and any
+// incidental Date math inherit it.
+process.env.TZ = process.env.TZ || 'America/Chicago';
+
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
diff --git a/inventory-server/scripts/full-update.js b/inventory-server/scripts/full-update.js
index 4e24641..ec508be 100644
--- a/inventory-server/scripts/full-update.js
+++ b/inventory-server/scripts/full-update.js
@@ -1,3 +1,7 @@
+// Run the whole update cycle in business time (see docs/TIME.md). The cron
+// that invokes this script does not set TZ; child scripts inherit it.
+process.env.TZ = process.env.TZ || 'America/Chicago';
+
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
diff --git a/inventory-server/scripts/import-from-prod.js b/inventory-server/scripts/import-from-prod.js
index cd1ef50..b4bd467 100644
--- a/inventory-server/scripts/import-from-prod.js
+++ b/inventory-server/scripts/import-from-prod.js
@@ -23,6 +23,17 @@ const IMPORT_STOCK_SNAPSHOTS = true;
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
// SSH configuration
+// Current UTC offset of America/Chicago ('-05:00' or '-06:00'), DST-aware.
+function currentChicagoOffset() {
+ const tzName = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/Chicago',
+ timeZoneName: 'longOffset',
+ })
+ .formatToParts(new Date())
+ .find((p) => p.type === 'timeZoneName').value; // e.g. 'GMT-05:00'
+ return tzName.replace('GMT', '') || '+00:00';
+}
+
const sshConfig = {
ssh: {
host: process.env.PROD_SSH_HOST,
@@ -40,7 +51,12 @@ const sshConfig = {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
- timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
+ // DATETIMEs on this server store Central wall-clock. Use the CURRENT
+ // Chicago offset (DST-aware) so both inbound parsing and outbound
+ // serialization are correct in winter (CST=-06:00) and summer
+ // (CDT=-05:00). adjustDateForMySQL() in utils.js remains as a runtime
+ // safety net (its correction is 0 when this offset matches the server).
+ timezone: currentChicagoOffset(),
},
localDbConfig: {
// PostgreSQL config for local
diff --git a/inventory-server/scripts/import/utils.js b/inventory-server/scripts/import/utils.js
index 564b86a..99ee727 100644
--- a/inventory-server/scripts/import/utils.js
+++ b/inventory-server/scripts/import/utils.js
@@ -58,7 +58,12 @@ async function setupConnections(sshConfig) {
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
);
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
- const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
+ // Driver offset from the actual config (e.g. '-06:00' in winter, '-05:00'
+ // in summer — set dynamically in import-from-prod.js).
+ const tzMatch = /^([+-])(\d{2}):(\d{2})$/.exec(sshConfig.prodDbConfig.timezone || '');
+ const driverOffsetMs = tzMatch
+ ? (tzMatch[1] === '-' ? -1 : 1) * (Number(tzMatch[2]) * 3600 + Number(tzMatch[3]) * 60) * 1000
+ : -5 * 3600 * 1000;
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
@@ -79,8 +84,13 @@ async function setupConnections(sshConfig) {
}
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
- // Setup PostgreSQL connection pool for local
- const localPool = new Pool(sshConfig.localDbConfig);
+ // Setup PostgreSQL connection pool for local.
+ // Pin the session timezone to business time (see docs/TIME.md) so import
+ // writes and any CURRENT_DATE/NOW()::date logic mean Chicago dates.
+ const localPool = new Pool({
+ options: '-c TimeZone=America/Chicago',
+ ...sshConfig.localDbConfig,
+ });
// Test the PostgreSQL connection
try {
diff --git a/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql b/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql
index ae0350e..82245e7 100644
--- a/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql
+++ b/inventory-server/scripts/metrics-new/calculate_brand_metrics.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per brand.
-- Dependencies: product_metrics, products, calculate_status table.
-- Frequency: Daily (after product_metrics update).
diff --git a/inventory-server/scripts/metrics-new/calculate_category_metrics.sql b/inventory-server/scripts/metrics-new/calculate_category_metrics.sql
index 6619d04..10158d4 100644
--- a/inventory-server/scripts/metrics-new/calculate_category_metrics.sql
+++ b/inventory-server/scripts/metrics-new/calculate_category_metrics.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
-- Frequency: Daily (after product_metrics update).
diff --git a/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql b/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql
index fef107f..8787437 100644
--- a/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql
+++ b/inventory-server/scripts/metrics-new/calculate_vendor_metrics.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per vendor.
-- Dependencies: product_metrics, products, purchase_orders, calculate_status table.
-- Frequency: Daily (after product_metrics update).
diff --git a/inventory-server/scripts/metrics-new/update_daily_snapshots.sql b/inventory-server/scripts/metrics-new/update_daily_snapshots.sql
index 395e528..ee784ff 100644
--- a/inventory-server/scripts/metrics-new/update_daily_snapshots.sql
+++ b/inventory-server/scripts/metrics-new/update_daily_snapshots.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates daily aggregated product data.
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
-- aggregates that don't match source tables after backfills), and always
diff --git a/inventory-server/scripts/metrics-new/update_lifecycle_forecasts.sql b/inventory-server/scripts/metrics-new/update_lifecycle_forecasts.sql
index 868ef7d..d3329a8 100644
--- a/inventory-server/scripts/metrics-new/update_lifecycle_forecasts.sql
+++ b/inventory-server/scripts/metrics-new/update_lifecycle_forecasts.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
diff --git a/inventory-server/scripts/metrics-new/update_periodic_metrics.sql b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql
index 4826cce..c2b79a9 100644
--- a/inventory-server/scripts/metrics-new/update_periodic_metrics.sql
+++ b/inventory-server/scripts/metrics-new/update_periodic_metrics.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates metrics that don't need hourly updates, like ABC class
-- and average lead time.
-- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status.
diff --git a/inventory-server/scripts/metrics-new/update_product_metrics.sql b/inventory-server/scripts/metrics-new/update_product_metrics.sql
index fa98fc5..44fbec1 100644
--- a/inventory-server/scripts/metrics-new/update_product_metrics.sql
+++ b/inventory-server/scripts/metrics-new/update_product_metrics.sql
@@ -1,3 +1,7 @@
+-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
+-- (pinned in the pool config and as the inventory_db database default).
+-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
+-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates the main product_metrics table based on current data
-- and aggregated daily snapshots. Uses UPSERT for idempotency.
-- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status.
diff --git a/inventory-server/scripts/metrics-new/utils/db.js b/inventory-server/scripts/metrics-new/utils/db.js
index 6d4abef..6283acd 100644
--- a/inventory-server/scripts/metrics-new/utils/db.js
+++ b/inventory-server/scripts/metrics-new/utils/db.js
@@ -10,6 +10,9 @@ const dbConfig = {
database: process.env.DB_NAME,
port: process.env.DB_PORT || 5432,
ssl: process.env.DB_SSL === 'true',
+ // Pin the session timezone to business time (see docs/TIME.md) — the
+ // metrics SQL window anchors (CURRENT_DATE etc.) must mean Chicago dates.
+ options: '-c TimeZone=America/Chicago',
// Add performance optimizations
max: 10, // connection pool max size
idleTimeoutMillis: 30000,
diff --git a/inventory-server/shared/business-time/index.js b/inventory-server/shared/business-time/index.js
new file mode 100644
index 0000000..b6a6901
--- /dev/null
+++ b/inventory-server/shared/business-time/index.js
@@ -0,0 +1,562 @@
+// Shared business-time module — the single source of truth for the time
+// convention (see docs/TIME.md and TIME_UNIFICATION_PLAN.md).
+//
+// 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. All boundary math here therefore uses Chicago days; Eastern exists
+// only for display formatting.
+//
+// MySQL DATETIME columns on 192.168.1.5 store Central wall-clock literals, so
+// toMySqlBound()/parseMySql() convert to/from Chicago wall-clock strings with
+// no hour shift. Never hand-roll an hour offset and never use a fixed
+// 'UTC-05:00' zone (wrong every winter).
+
+import { DateTime } from 'luxon';
+
+export const BUSINESS_TZ = 'America/Chicago'; // canonical
+export const OFFICE_TZ = 'America/New_York'; // display only
+
+const MYSQL_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
+const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+// ---------------------------------------------------------------------------
+// Parsing / coercion
+// ---------------------------------------------------------------------------
+
+/**
+ * Coerce any supported input (DateTime, Date, millis, ISO string, SQL string)
+ * to a luxon DateTime in the business zone. Date-only strings are interpreted
+ * as business dates (Chicago midnight). Zoned ISO strings keep their instant.
+ * Returns null for unparseable input.
+ */
+export function toBusinessTime(value) {
+ if (value == null || value === '') return null;
+
+ if (DateTime.isDateTime(value)) return value.setZone(BUSINESS_TZ);
+ if (value instanceof Date) return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
+ if (typeof value === 'number') return DateTime.fromMillis(value).setZone(BUSINESS_TZ);
+
+ if (typeof value === 'string') {
+ let dt = DateTime.fromISO(value, { zone: BUSINESS_TZ, setZone: false });
+ if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: BUSINESS_TZ });
+ return dt.isValid ? dt : null;
+ }
+
+ return null;
+}
+
+/** True if the input is a date-only string ('YYYY-MM-DD'). */
+export const isDateOnly = (value) => typeof value === 'string' && DATE_ONLY_RE.test(value);
+
+/**
+ * Parse a MySQL DATETIME/TIMESTAMP literal (Central wall-clock) into a
+ * DateTime in the business zone. Returns null for empty / zero dates.
+ */
+export function parseMySql(value) {
+ if (!value || value === '0000-00-00 00:00:00' || value === '0000-00-00') return null;
+ if (value instanceof Date) {
+ // Defensive: a driver not configured with dateStrings handed us a Date.
+ // Its instant depends on that driver's timezone config — pass it through
+ // as an instant rather than guessing.
+ return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
+ }
+ const dt = DateTime.fromSQL(String(value), { zone: BUSINESS_TZ });
+ return dt.isValid ? dt : null;
+}
+
+/**
+ * Serialize an instant to a MySQL bound: Central wall-clock
+ * 'YYYY-MM-DD HH:mm:ss'. Use with half-open intervals: col >= ? AND col < ?.
+ */
+export function toMySqlBound(value) {
+ const dt = toBusinessTime(value);
+ if (!dt) throw new Error('toMySqlBound: invalid datetime input');
+ return dt.toFormat(MYSQL_DATETIME_FORMAT);
+}
+
+// ---------------------------------------------------------------------------
+// "Today" and day boundaries
+// ---------------------------------------------------------------------------
+
+/** Now, as a DateTime in the business zone. */
+export const businessNow = () => DateTime.now().setZone(BUSINESS_TZ);
+
+/** Start of the current business day (Chicago midnight = 1am Eastern). */
+export const businessToday = () => businessNow().startOf('day');
+
+/** The current business date as 'YYYY-MM-DD'. The only sanctioned "today". */
+export const businessTodayStr = () => businessNow().toISODate();
+
+/** Start of the business day containing the given instant/date. */
+export function businessDayStart(value = businessNow()) {
+ const dt = toBusinessTime(value);
+ if (!dt) throw new Error('businessDayStart: invalid datetime input');
+ return dt.startOf('day');
+}
+
+/** Half-open range [start, nextStart) of the business day containing value. */
+export function businessDayRange(value = businessNow()) {
+ const start = businessDayStart(value);
+ return { start, end: start.plus({ days: 1 }) };
+}
+
+/** Business date ('YYYY-MM-DD') of an instant. */
+export function businessDateOf(value) {
+ const dt = toBusinessTime(value);
+ return dt ? dt.toISODate() : null;
+}
+
+/**
+ * Format a node-postgres DATE-column value as 'YYYY-MM-DD'.
+ *
+ * node-postgres materializes DATE columns as a JS Date at process-local
+ * midnight, so formatting in LOCAL time round-trips to the same calendar date
+ * regardless of the process zone. (The old .toISOString().split('T')[0] idiom
+ * formatted in UTC, which emitted the PREVIOUS day for any zone east of UTC.)
+ * Strings (e.g. from ::text casts) pass through.
+ */
+export function formatDateCol(value) {
+ if (value == null) return null;
+ if (typeof value === 'string') return value.slice(0, 10);
+ if (value instanceof Date) {
+ const y = value.getFullYear();
+ const m = String(value.getMonth() + 1).padStart(2, '0');
+ const d = String(value.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+ }
+ return businessDateOf(value);
+}
+
+/** Format an instant in office (Eastern) time for display. */
+export function formatOffice(value, fmt = 'LLL d, yyyy h:mm a') {
+ const dt = toBusinessTime(value);
+ return dt ? dt.setZone(OFFICE_TZ).toFormat(fmt) : '';
+}
+
+// ---------------------------------------------------------------------------
+// Named range presets — ONE vocabulary for every server
+// ---------------------------------------------------------------------------
+
+export const RANGE_PRESETS = [
+ 'today',
+ 'yesterday',
+ 'twoDaysAgo',
+ 'thisWeek',
+ 'lastWeek',
+ 'thisMonth',
+ 'lastMonth',
+ 'last7days',
+ 'last30days',
+ 'last90days',
+ 'previous7days',
+ 'previous30days',
+ 'previous90days',
+ 'custom',
+];
+
+export const isKnownRange = (name) => RANGE_PRESETS.includes(name);
+
+// Weeks start on Sunday (business convention).
+function weekStart(dt) {
+ const day = dt.startOf('day');
+ // luxon weekday: 1 = Monday … 7 = Sunday
+ return day.weekday === 7 ? day : day.minus({ days: day.weekday });
+}
+
+/**
+ * Resolve a named preset to a half-open range { start, end } of business-zone
+ * DateTimes (end is EXCLUSIVE — the start of the day after the last included
+ * business day). Throws on unknown names; 'custom' must be resolved by the
+ * caller via customRange().
+ */
+export function rangeForPreset(name, now = businessNow()) {
+ const current = toBusinessTime(now);
+ if (!current) throw new Error('rangeForPreset: invalid reference time');
+ const todayStart = current.startOf('day');
+ const tomorrowStart = todayStart.plus({ days: 1 });
+
+ switch (name) {
+ case 'today':
+ return { start: todayStart, end: tomorrowStart };
+ case 'yesterday':
+ return { start: todayStart.minus({ days: 1 }), end: todayStart };
+ case 'twoDaysAgo':
+ return { start: todayStart.minus({ days: 2 }), end: todayStart.minus({ days: 1 }) };
+ case 'thisWeek':
+ return { start: weekStart(current), end: tomorrowStart };
+ case 'lastWeek': {
+ const start = weekStart(current).minus({ weeks: 1 });
+ return { start, end: start.plus({ weeks: 1 }) };
+ }
+ case 'thisMonth':
+ return { start: todayStart.startOf('month'), end: tomorrowStart };
+ case 'lastMonth': {
+ const start = todayStart.startOf('month').minus({ months: 1 });
+ return { start, end: start.plus({ months: 1 }) };
+ }
+ case 'last7days':
+ return { start: todayStart.minus({ days: 6 }), end: tomorrowStart };
+ case 'last30days':
+ return { start: todayStart.minus({ days: 29 }), end: tomorrowStart };
+ case 'last90days':
+ return { start: todayStart.minus({ days: 89 }), end: tomorrowStart };
+ case 'previous7days': {
+ const currentStart = todayStart.minus({ days: 6 });
+ return { start: currentStart.minus({ days: 7 }), end: currentStart };
+ }
+ case 'previous30days': {
+ const currentStart = todayStart.minus({ days: 29 });
+ return { start: currentStart.minus({ days: 30 }), end: currentStart };
+ }
+ case 'previous90days': {
+ const currentStart = todayStart.minus({ days: 89 });
+ return { start: currentStart.minus({ days: 90 }), end: currentStart };
+ }
+ default:
+ throw new Error(`Unknown time range: ${name}`);
+ }
+}
+
+/**
+ * Resolve a custom range to half-open { start, end }. Date-only strings are
+ * business dates: start = that Chicago midnight, end = the day AFTER the end
+ * date (so both endpoints are included as full business days). Zoned ISO
+ * instants are used as-is (end treated as exclusive).
+ */
+export function customRange(startInput, endInput) {
+ if (!startInput || !endInput) throw new Error('customRange: both bounds required');
+ const start = isDateOnly(startInput)
+ ? DateTime.fromISO(startInput, { zone: BUSINESS_TZ }).startOf('day')
+ : toBusinessTime(startInput);
+ let end = isDateOnly(endInput)
+ ? DateTime.fromISO(endInput, { zone: BUSINESS_TZ }).startOf('day').plus({ days: 1 })
+ : toBusinessTime(endInput);
+ if (!start || !start.isValid || !end || !end.isValid) {
+ throw new Error('customRange: invalid bounds');
+ }
+ if (end < start) throw new Error('customRange: end before start');
+ return { start, end };
+}
+
+/**
+ * Previous comparable range. Accepts a preset name (uses the conventional
+ * predecessor: today→yesterday, thisWeek→lastWeek, …) or a { start, end }
+ * half-open range (shifts back by its own duration).
+ */
+export function previousRange(nameOrRange, now = businessNow()) {
+ if (typeof nameOrRange === 'string') {
+ const map = {
+ today: 'yesterday',
+ yesterday: 'twoDaysAgo',
+ thisWeek: 'lastWeek',
+ thisMonth: 'lastMonth',
+ last7days: 'previous7days',
+ last30days: 'previous30days',
+ last90days: 'previous90days',
+ };
+ const prev = map[nameOrRange];
+ if (prev) return rangeForPreset(prev, now);
+ // Calendar-unit presets without a named predecessor.
+ if (nameOrRange === 'twoDaysAgo') {
+ const { start } = rangeForPreset('twoDaysAgo', now);
+ return { start: start.minus({ days: 1 }), end: start };
+ }
+ if (nameOrRange === 'lastWeek') {
+ const { start } = rangeForPreset('lastWeek', now);
+ return { start: start.minus({ weeks: 1 }), end: start };
+ }
+ if (nameOrRange === 'lastMonth') {
+ const { start } = rangeForPreset('lastMonth', now);
+ return { start: start.minus({ months: 1 }), end: start };
+ }
+ return null;
+ }
+ const { start, end } = nameOrRange || {};
+ if (!start || !end) return null;
+ const duration = end.diff(start);
+ return { start: start.minus(duration), end: start };
+}
+
+// ---------------------------------------------------------------------------
+// MySQL query helpers (acot-server style)
+// ---------------------------------------------------------------------------
+
+const RANGE_LABELS = {
+ today: 'Today',
+ yesterday: 'Yesterday',
+ twoDaysAgo: 'Two Days Ago',
+ thisWeek: 'This Week',
+ lastWeek: 'Last Week',
+ thisMonth: 'This Month',
+ lastMonth: 'Last Month',
+ last7days: 'Last 7 Days',
+ last30days: 'Last 30 Days',
+ last90days: 'Last 90 Days',
+ previous7days: 'Previous 7 Days',
+ previous30days: 'Previous 30 Days',
+ previous90days: 'Previous 90 Days',
+};
+
+export const getTimeRangeLabel = (timeRange) => RANGE_LABELS[timeRange] || timeRange;
+
+/** Eastern display date for a range label ('Jun 12, 2026'). */
+export function formatBusinessDate(value) {
+ const dt = toBusinessTime(value);
+ return dt ? dt.setZone(OFFICE_TZ).toFormat('LLL d, yyyy') : '';
+}
+
+/**
+ * Build a half-open MySQL WHERE fragment + Central wall-clock bound params
+ * for a named or custom range. The where clause repeats the column for both
+ * bounds so callers re-targeting another column MUST replace globally
+ * (/date_placed/g).
+ */
+export function getTimeRangeConditions(timeRange, startDate, endDate) {
+ let range;
+ let label;
+
+ if (timeRange === 'custom' && startDate && endDate) {
+ range = customRange(startDate, endDate);
+ // Display the last *included* business day, not the exclusive endpoint.
+ label = `${formatBusinessDate(range.start)} - ${formatBusinessDate(range.end.minus({ milliseconds: 1 }))}`;
+ } else {
+ const normalized = timeRange || 'today';
+ range = rangeForPreset(normalized);
+ label = getTimeRangeLabel(normalized);
+ }
+
+ return {
+ whereClause: 'date_placed >= ? AND date_placed < ?',
+ params: [toMySqlBound(range.start), toMySqlBound(range.end)],
+ range,
+ start: range.start.toJSDate(),
+ end: range.end.toJSDate(), // EXCLUSIVE
+ dateRange: {
+ start: range.start.toUTC().toISO(),
+ end: range.end.toUTC().toISO(), // EXCLUSIVE
+ label,
+ },
+ };
+}
+
+/** Half-open JS Date bounds for a named range (end exclusive). */
+export function getBusinessDayBounds(timeRange) {
+ const range = rangeForPreset(timeRange);
+ return { start: range.start.toJSDate(), end: range.end.toJSDate() };
+}
+
+/** Parse a MySQL datetime literal to a JS Date (legacy convenience). */
+export function parseBusinessDate(mysqlDatetime) {
+ const dt = parseMySql(mysqlDatetime);
+ return dt ? dt.toUTC().toJSDate() : null;
+}
+
+/** Serialize any instant to a MySQL Central wall-clock string, null-safe. */
+export function formatMySQLDate(value) {
+ const dt = toBusinessTime(value);
+ return dt ? dt.toFormat(MYSQL_DATETIME_FORMAT) : null;
+}
+
+// Legacy helpers kept for consumers that need raw day/week math. getDayEnd
+// returns the half-open endpoint (next day start); prefer businessDayRange.
+export const _internal = {
+ getDayStart: (input) => businessDayStart(input ?? businessNow()),
+ getDayEnd: (input) => businessDayStart(input ?? businessNow()).plus({ days: 1 }),
+ getWeekStart: (input) => weekStart(toBusinessTime(input ?? businessNow())),
+ getRangeForTimeRange: (timeRange, now) => rangeForPreset(timeRange, now),
+ BUSINESS_DAY_START_HOUR: 1, // 1am Eastern == midnight Central
+};
+
+// ---------------------------------------------------------------------------
+// TimeManager — compatibility class for the dashboard/klaviyo tree.
+//
+// Same method surface as the old dashboard/utils/time.utils.js, with the
+// defects fixed:
+// - getDayEnd: was next-start minus 1 MINUTE (a 59.999s hole every day);
+// now next-start minus 1 millisecond. Kept inclusive (rather than
+// half-open) because consumers iterate `while (day <= end)` — a half-open
+// end would add a spurious trailing bucket. Klaviyo API bounds use
+// less-than(end), so the residual 1ms hole is negligible.
+// - groupEventsByInterval: grouped in the event string's own zone; now
+// groups days by business date and hour/week/month in office time.
+// ---------------------------------------------------------------------------
+
+export class TimeManager {
+ constructor(dayStartHour = 1) {
+ this.timezone = OFFICE_TZ;
+ this.dayStartHour = dayStartHour; // retained for API compat; 1 == Chicago midnight
+ this.weekStartDay = 7; // Sunday
+ }
+
+ getNow() {
+ return DateTime.now().setZone(this.timezone);
+ }
+
+ toDateTime(date) {
+ if (!date) return null;
+ if (DateTime.isDateTime(date)) return date.setZone(this.timezone);
+ const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
+ if (!dt.isValid) {
+ console.error('[TimeManager] Invalid date input:', date);
+ return null;
+ }
+ return dt.setZone(this.timezone);
+ }
+
+ getDayStart(dt = this.getNow()) {
+ if (!dt.isValid) return this.getNow();
+ return businessDayStart(dt).setZone(this.timezone);
+ }
+
+ getDayEnd(dt = this.getNow()) {
+ if (!dt.isValid) return this.getNow();
+ return businessDayStart(dt).plus({ days: 1 }).minus({ milliseconds: 1 }).setZone(this.timezone);
+ }
+
+ getWeekStart(dt = this.getNow()) {
+ if (!dt.isValid) return this.getNow();
+ return weekStart(toBusinessTime(dt)).setZone(this.timezone);
+ }
+
+ formatForAPI(date) {
+ const dt = this.toDateTime(date);
+ if (!dt || !dt.isValid) {
+ console.error('[TimeManager] Invalid date for API:', date);
+ return null;
+ }
+ return dt.toUTC().toISO();
+ }
+
+ formatForDisplay(date) {
+ const dt = this.toDateTime(date);
+ if (!dt || !dt.isValid) return '';
+ return dt.toFormat('LLL d, yyyy h:mm a');
+ }
+
+ isValidDateRange(start, end) {
+ const startDt = this.toDateTime(start);
+ const endDt = this.toDateTime(end);
+ return startDt && endDt && endDt > startDt;
+ }
+
+ getLastNHours(hours) {
+ const now = this.getNow();
+ return { start: now.minus({ hours }), end: now };
+ }
+
+ getLastNDays(days) {
+ const now = this.getNow();
+ return { start: this.getDayStart(now).minus({ days }), end: this.getDayEnd(now) };
+ }
+
+ getDateRange(period) {
+ if (period === 'custom') {
+ console.warn('[TimeManager] Custom ranges should use getCustomRange method');
+ return null;
+ }
+ const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
+ try {
+ const { start, end } = rangeForPreset(normalized);
+ return {
+ start: start.setZone(this.timezone),
+ end: end.minus({ milliseconds: 1 }).setZone(this.timezone),
+ };
+ } catch {
+ console.warn(`[TimeManager] Unknown period: ${period}`);
+ return null;
+ }
+ }
+
+ getCustomRange(startDate, endDate) {
+ if (!startDate || !endDate) {
+ console.error('[TimeManager] Custom range requires both start and end dates');
+ return null;
+ }
+ try {
+ const { start, end } = customRange(startDate, endDate);
+ const inclusiveEnd = isDateOnly(endDate) ? end.minus({ milliseconds: 1 }) : end;
+ return { start: start.setZone(this.timezone), end: inclusiveEnd.setZone(this.timezone) };
+ } catch (error) {
+ console.error('[TimeManager] Invalid dates provided for custom range:', error.message);
+ return null;
+ }
+ }
+
+ getPreviousPeriod(period, now = this.getNow()) {
+ const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
+ const current = (() => {
+ try {
+ return rangeForPreset(normalized, now);
+ } catch {
+ return null;
+ }
+ })();
+ if (!current) {
+ console.warn(`[TimeManager] No previous period defined for: ${period}`);
+ return null;
+ }
+ const prev =
+ previousRange(normalized, now) ||
+ previousRange(current, now);
+ if (!prev) return null;
+ return {
+ start: prev.start.setZone(this.timezone),
+ end: prev.end.minus({ milliseconds: 1 }).setZone(this.timezone),
+ };
+ }
+
+ formatDuration(ms) {
+ return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
+ }
+
+ getRelativeTime(date) {
+ const dt = this.toDateTime(date);
+ if (!dt) return '';
+ return dt.toRelative();
+ }
+
+ groupEventsByInterval(events, interval = 'day', property = null) {
+ if (!events?.length) return [];
+
+ const groupedData = new Map();
+
+ for (const event of events) {
+ const instant = DateTime.fromISO(event.attributes.datetime, { setZone: true });
+ if (!instant.isValid) continue;
+
+ let groupKey;
+ switch (interval) {
+ case 'hour':
+ groupKey = instant.setZone(OFFICE_TZ).startOf('hour').toISO();
+ break;
+ case 'week':
+ groupKey = weekStart(instant.setZone(BUSINESS_TZ)).setZone(OFFICE_TZ).toISO();
+ break;
+ case 'month':
+ groupKey = instant.setZone(BUSINESS_TZ).startOf('month').setZone(OFFICE_TZ).toISO();
+ break;
+ case 'day':
+ default:
+ // Group by BUSINESS date (Chicago calendar day = 1am-ET day rule).
+ groupKey = instant.setZone(BUSINESS_TZ).startOf('day').setZone(OFFICE_TZ).toISO();
+ }
+
+ const existingGroup = groupedData.get(groupKey) || { datetime: groupKey, count: 0, value: 0 };
+ existingGroup.count++;
+
+ if (property) {
+ const props = event.attributes?.event_properties || event.attributes?.properties || {};
+ const value =
+ property === '$value' ? Number(event.attributes?.value || 0) : Number(props[property] || 0);
+ existingGroup.value = (existingGroup.value || 0) + value;
+ }
+
+ groupedData.set(groupKey, existingGroup);
+ }
+
+ return Array.from(groupedData.values()).sort(
+ (a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)
+ );
+ }
+}
diff --git a/inventory-server/shared/db/pg.js b/inventory-server/shared/db/pg.js
index f3b9a05..b9d690c 100644
--- a/inventory-server/shared/db/pg.js
+++ b/inventory-server/shared/db/pg.js
@@ -15,5 +15,8 @@ export function createPool(envPrefix = 'DB', overrides = {}) {
max: overrides.max ?? 20,
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
+ // Pin the session timezone to business time (see docs/TIME.md) so date
+ // bucketing can't silently revert if the database default changes.
+ options: overrides.options ?? '-c TimeZone=America/Chicago',
});
}
diff --git a/inventory-server/shared/package.json b/inventory-server/shared/package.json
index 58bc2d9..ca14974 100644
--- a/inventory-server/shared/package.json
+++ b/inventory-server/shared/package.json
@@ -14,7 +14,8 @@
"./logging": "./logging/index.js",
"./errors/handler": "./errors/handler.js",
"./cors/policy": "./cors/policy.js",
- "./rate-limit/login": "./rate-limit/login.js"
+ "./rate-limit/login": "./rate-limit/login.js",
+ "./business-time": "./business-time/index.js"
},
"dependencies": {
"cors": "^2.8.5",
@@ -23,6 +24,7 @@
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pino": "^9.5.0",
- "pino-http": "^10.3.0"
+ "pino-http": "^10.3.0",
+ "luxon": "^3.7.2"
}
-}
+}
\ No newline at end of file
diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js
index 992a1ba..ca6142b 100644
--- a/inventory-server/src/routes/analytics.js
+++ b/inventory-server/src/routes/analytics.js
@@ -1,4 +1,5 @@
import express from 'express';
+import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Forecasting: summarize sales for products received in a period by brand
@@ -15,13 +16,12 @@ router.get('/forecast', async (req, res) => {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
- // Default to last 30 days if no dates provided
- const endDate = endDateStr ? new Date(endDateStr) : new Date();
- const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
-
- // Normalize to date boundaries for consistency
- const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
- const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
+ // Default to last 30 business days if no dates provided. Params are
+ // date-only business dates ('YYYY-MM-DD'); instants are converted.
+ const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
+ const startISO = startDateStr
+ ? businessDateOf(startDateStr)
+ : new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const sql = `
WITH params AS (
@@ -177,11 +177,10 @@ router.get('/forecast-v2', async (req, res) => {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
- const endDate = endDateStr ? new Date(endDateStr) : new Date();
- const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
-
- const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
- const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
+ const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
+ const startISO = startDateStr
+ ? businessDateOf(startDateStr)
+ : new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const sql = `
WITH params AS (
diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js
index 25563a9..766b865 100644
--- a/inventory-server/src/routes/dashboard.js
+++ b/inventory-server/src/routes/dashboard.js
@@ -1,5 +1,6 @@
import express from 'express';
import db from '../utils/db.js';
+import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Helper function to execute queries using the connection pool
@@ -306,15 +307,13 @@ router.get('/replenishment/metrics', async (req, res) => {
// Reads from product_forecasts table (lifecycle-aware forecasting pipeline).
// Falls back to velocity-based projection if forecast table is empty.
router.get('/forecast/metrics', async (req, res) => {
- const today = new Date();
- const thirtyDaysOut = new Date(today);
- thirtyDaysOut.setDate(today.getDate() + 30);
-
- const startDate = req.query.startDate ? new Date(req.query.startDate) : today;
- const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut;
- const startISO = startDate.toISOString().split('T')[0];
- const endISO = endDate.toISOString().split('T')[0];
- const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)));
+ // Business dates (Chicago calendar — see docs/TIME.md). Inputs may be
+ // date-only strings or full instants.
+ const startISO = req.query.startDate ? businessDateOf(req.query.startDate) : businessTodayStr();
+ const endISO = req.query.endDate
+ ? businessDateOf(req.query.endDate)
+ : businessNow().plus({ days: 30 }).toISODate();
+ const days = Math.max(1, Math.round((Date.parse(endISO) - Date.parse(startISO)) / (1000 * 60 * 60 * 24)));
try {
// Check if product_forecasts has data
@@ -331,9 +330,7 @@ router.get('/forecast/metrics', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
);
- const forecastHorizonISO = horizonRow.max_date instanceof Date
- ? horizonRow.max_date.toISOString().split('T')[0]
- : horizonRow.max_date;
+ const forecastHorizonISO = formatDateCol(horizonRow.max_date);
const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00');
const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO;
const needsExtrapolation = endISO > forecastHorizonISO;
@@ -376,7 +373,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]);
const dailyForecasts = dailyRows.map(d => ({
- date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
+ date: formatDateCol(d.date),
units: parseFloat(d.units) || 0,
revenue: parseFloat(d.revenue) || 0,
confidence: confidenceLevel,
@@ -475,7 +472,7 @@ router.get('/forecast/metrics', async (req, res) => {
const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits;
dailyForecasts.push({
- date: d.toISOString().split('T')[0],
+ date: formatDateCol(d),
units: parseFloat(estUnits.toFixed(1)),
revenue: parseFloat(estRevenue.toFixed(2)),
confidence: 0, // lower confidence for extrapolated data
@@ -539,7 +536,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]);
const dailyForecastsByPhase = dailyPhaseRows.map(d => ({
- date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
+ date: formatDateCol(d.date),
preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 0,
@@ -597,11 +594,12 @@ router.get('/forecast/metrics', async (req, res) => {
const dailyRevenue = parseFloat(totals.daily_revenue) || 0;
const dailyForecasts = [];
+ const fallbackStart = new Date(startISO + 'T00:00:00'); // local midnight — round-trips via formatDateCol
for (let i = 0; i < days; i++) {
- const d = new Date(startDate);
- d.setDate(startDate.getDate() + i);
+ const d = new Date(fallbackStart);
+ d.setDate(fallbackStart.getDate() + i);
dailyForecasts.push({
- date: d.toISOString().split('T')[0],
+ date: formatDateCol(d),
units: parseFloat(dailyUnits.toFixed(1)),
revenue: parseFloat(dailyRevenue.toFixed(2)),
confidence: 0,
@@ -776,7 +774,7 @@ router.get('/forecast/accuracy', async (req, res) => {
`);
const accuracyTrend = trendRows.map(r => ({
- date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
+ date: formatDateCol(r.run_date),
mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null,
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : null,
@@ -796,7 +794,7 @@ router.get('/forecast/accuracy', async (req, res) => {
`);
const accuracyTrendWeekly = weeklyTrendRows.map(r => ({
- date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
+ date: formatDateCol(r.run_date),
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
naiveWmape: r.naive_wmape != null ? parseFloat((parseFloat(r.naive_wmape) * 100).toFixed(1)) : null,
fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
@@ -808,12 +806,8 @@ router.get('/forecast/accuracy', async (req, res) => {
computedAt,
daysOfHistory: parseInt(historyInfo.days_of_history) || 0,
historyRange: {
- from: historyInfo.earliest_date instanceof Date
- ? historyInfo.earliest_date.toISOString().split('T')[0]
- : historyInfo.earliest_date,
- to: historyInfo.latest_date instanceof Date
- ? historyInfo.latest_date.toISOString().split('T')[0]
- : historyInfo.latest_date,
+ from: formatDateCol(historyInfo.earliest_date),
+ to: formatDateCol(historyInfo.latest_date),
},
overall: shapeOverall(overall),
overallInclDormant: shapeOverall(overallInclDormant),
@@ -1038,10 +1032,9 @@ router.get('/best-sellers', async (req, res) => {
// GET /dashboard/year-revenue-estimate
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
router.get('/year-revenue-estimate', async (req, res) => {
- const now = new Date();
- const yearStart = `${now.getFullYear()}-01-01`;
- const todayISO = now.toISOString().split('T')[0];
- const yearEndISO = `${now.getFullYear()}-12-31`;
+ const todayISO = businessTodayStr(); // business 'today', not UTC
+ const yearStart = `${todayISO.slice(0, 4)}-01-01`;
+ const yearEndISO = `${todayISO.slice(0, 4)}-12-31`;
try {
// YTD actual revenue from orders
@@ -1055,11 +1048,7 @@ router.get('/year-revenue-estimate', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
);
- const forecastHorizonISO = horizonRow?.max_date
- ? (horizonRow.max_date instanceof Date
- ? horizonRow.max_date.toISOString().split('T')[0]
- : horizonRow.max_date)
- : todayISO;
+ const forecastHorizonISO = horizonRow?.max_date ? formatDateCol(horizonRow.max_date) : todayISO;
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
@@ -1111,13 +1100,9 @@ router.get('/year-revenue-estimate', async (req, res) => {
// GET /dashboard/sales/metrics
// Returns sales metrics for specified period
router.get('/sales/metrics', async (req, res) => {
- // Default to last 30 days if no date range provided
- const today = new Date();
- const thirtyDaysAgo = new Date(today);
- thirtyDaysAgo.setDate(today.getDate() - 30);
-
- const startDate = req.query.startDate || thirtyDaysAgo.toISOString();
- const endDate = req.query.endDate || today.toISOString();
+ // Default: trailing 30 business days ending now (Chicago day start).
+ const startDate = req.query.startDate || businessNow().minus({ days: 30 }).startOf('day').toISO();
+ const endDate = req.query.endDate || new Date().toISOString();
try {
// Get daily orders and totals for the specified period
@@ -1187,13 +1172,13 @@ router.get('/sales/metrics', async (req, res) => {
totalCogs: parseFloat(metrics?.total_cogs) || 0,
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
dailySales: dailyRows.map(day => ({
- date: day.sale_date,
+ date: formatDateCol(day.sale_date),
units: parseInt(day.total_units) || 0,
revenue: parseFloat(day.total_revenue) || 0,
cogs: parseFloat(day.total_cogs) || 0
})),
dailySalesByPhase: dailyPhaseRows.map(d => ({
- date: d.sale_date,
+ date: formatDateCol(d.sale_date),
preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 0,
diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js
index cb4fb31..592d2ff 100644
--- a/inventory-server/src/routes/import.js
+++ b/inventory-server/src/routes/import.js
@@ -676,7 +676,10 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
- timezone: 'Z'
+ // DATETIME columns store Central wall-clock literals — return them as
+ // strings; parse with shared/business-time parseMySql() if instants are
+ // ever needed (see docs/TIME.md).
+ dateStrings: true
};
return new Promise((resolve, reject) => {
diff --git a/inventory-server/src/routes/orders.js b/inventory-server/src/routes/orders.js
index 1c7c564..9ee185c 100644
--- a/inventory-server/src/routes/orders.js
+++ b/inventory-server/src/routes/orders.js
@@ -1,4 +1,5 @@
import express from 'express';
+import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Get all orders with pagination, filtering, and sorting
@@ -10,8 +11,9 @@ router.get('/', async (req, res) => {
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
- const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null;
- const toDate = req.query.toDate ? new Date(req.query.toDate) : null;
+ // Date filters are BUSINESS dates ('YYYY-MM-DD', Chicago calendar).
+ const fromDate = req.query.fromDate ? businessDateOf(req.query.fromDate) : null;
+ const toDate = req.query.toDate ? businessDateOf(req.query.toDate) : null;
const minAmount = parseFloat(req.query.minAmount) || 0;
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
const sortColumn = req.query.sortColumn || 'date';
@@ -35,14 +37,14 @@ router.get('/', async (req, res) => {
}
if (fromDate) {
- conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`);
- params.push(fromDate.toISOString());
+ conditions.push(`DATE(o1.date) >= $${paramCounter}::date`);
+ params.push(fromDate);
paramCounter++;
}
if (toDate) {
- conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`);
- params.push(toDate.toISOString());
+ conditions.push(`DATE(o1.date) <= $${paramCounter}::date`);
+ params.push(toDate);
paramCounter++;
}
diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js
index b188d40..6587475 100644
--- a/inventory-server/src/routes/products.js
+++ b/inventory-server/src/routes/products.js
@@ -1,4 +1,5 @@
import express from 'express';
+import { formatDateCol } from '../../shared/business-time/index.js';
import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js';
const router = express.Router();
@@ -1003,7 +1004,7 @@ router.get('/:id/forecast', async (req, res) => {
phase,
method,
forecast: rows.map(r => ({
- date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
+ date: formatDateCol(r.date),
units: parseFloat(r.units) || 0,
revenue: parseFloat(r.revenue) || 0,
confidenceLower: parseFloat(r.confidence_lower) || 0,
diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js
index f30dff8..84f86a7 100644
--- a/inventory-server/src/routes/purchase-orders.js
+++ b/inventory-server/src/routes/purchase-orders.js
@@ -1,4 +1,5 @@
import express from 'express';
+import { businessNow } from '../../shared/business-time/index.js';
const router = express.Router();
// Status code constants
@@ -716,7 +717,7 @@ router.get('/category-analysis', async (req, res) => {
const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago
- const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+ const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
const { rows: analysis } = await pool.query(`
WITH receiving_costs AS (
@@ -769,7 +770,7 @@ router.get('/vendor-analysis', async (req, res) => {
const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago
- const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+ const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
const { rows: metrics } = await pool.query(`
WITH receiving_data AS (
diff --git a/inventory-server/src/utils/db.js b/inventory-server/src/utils/db.js
index cf95450..2f48ef4 100644
--- a/inventory-server/src/utils/db.js
+++ b/inventory-server/src/utils/db.js
@@ -5,7 +5,13 @@ const { Pool } = pg;
let pool;
export function initPool(config) {
- pool = new Pool(config);
+ pool = new Pool({
+ // Pin the session timezone to business time (matches the
+ // `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` default)
+ // so a DB restore/migration can't silently revert date semantics.
+ options: '-c TimeZone=America/Chicago',
+ ...config,
+ });
return pool;
}
diff --git a/inventory-server/src/utils/dbConnection.js b/inventory-server/src/utils/dbConnection.js
index 21a306e..21edaa4 100644
--- a/inventory-server/src/utils/dbConnection.js
+++ b/inventory-server/src/utils/dbConnection.js
@@ -97,7 +97,10 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: Number(process.env.PROD_DB_PORT) || 3306,
- timezone: 'Z',
+ // DATETIME columns store Central wall-clock literals — return them as
+ // strings; parse with shared/business-time parseMySql() if instants are
+ // ever needed (see docs/TIME.md).
+ dateStrings: true,
};
return new Promise((resolve, reject) => {
diff --git a/inventory/src/components/analytics/InventoryFlow.tsx b/inventory/src/components/analytics/InventoryFlow.tsx
index 1c9f008..e479c2e 100644
--- a/inventory/src/components/analytics/InventoryFlow.tsx
+++ b/inventory/src/components/analytics/InventoryFlow.tsx
@@ -17,6 +17,7 @@ import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
+import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface FlowPoint {
date: string;
@@ -71,21 +72,7 @@ export function InventoryFlow() {
Daily cost of goods received vs cost of goods sold
-
- {([30, 90] as Period[]).map((p) => (
- setPeriod(p)}
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
- period === p
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
- }`}
- >
- {`${p}D`}
-
- ))}
-
+ setPeriod(d as Period)} options={[30, 90]} />
diff --git a/inventory/src/components/analytics/InventoryTrends.tsx b/inventory/src/components/analytics/InventoryTrends.tsx
index 07b01d8..b983ccc 100644
--- a/inventory/src/components/analytics/InventoryTrends.tsx
+++ b/inventory/src/components/analytics/InventoryTrends.tsx
@@ -14,6 +14,7 @@ import {
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
+import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface TrendPoint {
date: string;
@@ -52,21 +53,7 @@ export function InventoryTrends() {
Units sold per day with stockout product count overlay
-
- {([30, 90, 365] as Period[]).map((p) => (
- setPeriod(p)}
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
- period === p
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
- }`}
- >
- {p === 365 ? '1Y' : `${p}D`}
-
- ))}
-
+ setPeriod(d as Period)} options={[30, 90, 365]} />
diff --git a/inventory/src/components/analytics/InventoryValueTrend.tsx b/inventory/src/components/analytics/InventoryValueTrend.tsx
index 78d4d75..b47f9f7 100644
--- a/inventory/src/components/analytics/InventoryValueTrend.tsx
+++ b/inventory/src/components/analytics/InventoryValueTrend.tsx
@@ -15,6 +15,7 @@ import {
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
+import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface ValuePoint {
date: string;
@@ -69,21 +70,7 @@ export function InventoryValueTrend() {
)}
-
- {([30, 90, 365] as Period[]).map((p) => (
- setPeriod(p)}
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
- period === p
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted text-muted-foreground hover:bg-muted/80'
- }`}
- >
- {p === 365 ? '1Y' : `${p}D`}
-
- ))}
-
+ setPeriod(d as Period)} options={[30, 90, 365]} />
diff --git a/inventory/src/components/dashboard/DateTime.jsx b/inventory/src/components/dashboard/DateTime.jsx
index e39dbe3..c0d3924 100644
--- a/inventory/src/components/dashboard/DateTime.jsx
+++ b/inventory/src/components/dashboard/DateTime.jsx
@@ -30,6 +30,10 @@ import {
} from 'lucide-react';
import { cn } from "@/lib/utils";
+const OFFICE_TZ = 'America/New_York'; // office wall-clock (see docs/TIME.md)
+const officeHour = (date) =>
+ Number(date.toLocaleString('en-US', { hour: 'numeric', hour12: false, timeZone: OFFICE_TZ })) % 24;
+
const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const [datetime, setDatetime] = useState(new Date());
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
@@ -93,11 +97,10 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
}, [prevTime]);
function getTimeComponents(date) {
- let hours = date.getHours();
- const minutes = date.getMinutes();
- const ampm = hours >= 12 ? 'PM' : 'AM';
- hours = hours % 12;
- hours = hours ? hours : 12;
+ const hour24 = officeHour(date);
+ const minutes = Number(date.toLocaleString('en-US', { minute: 'numeric', timeZone: OFFICE_TZ }));
+ const ampm = hour24 >= 12 ? 'PM' : 'AM';
+ const hours = hour24 % 12 || 12;
return {
hours: hours.toString(),
minutes: minutes.toString().padStart(2, '0'),
@@ -107,9 +110,9 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const formatDate = (date) => {
return {
- weekday: date.toLocaleDateString('en-US', { weekday: 'long' }),
- month: date.toLocaleDateString('en-US', { month: 'long' }),
- day: date.getDate()
+ weekday: date.toLocaleDateString('en-US', { weekday: 'long', timeZone: OFFICE_TZ }),
+ month: date.toLocaleDateString('en-US', { month: 'long', timeZone: OFFICE_TZ }),
+ day: Number(date.toLocaleDateString('en-US', { day: 'numeric', timeZone: OFFICE_TZ }))
};
};
@@ -117,7 +120,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
if (!weatherCode) return ;
const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
- const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
+ const isNight = officeHour(currentTime) >= 18 || officeHour(currentTime) < 6;
switch (true) {
case code >= 200 && code < 300:
@@ -137,7 +140,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
case code === 781:
return ;
case code === 800:
- return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
+ return officeHour(currentTime) >= 6 && officeHour(currentTime) < 18 ? (
) : (
@@ -227,10 +230,11 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const formatTime = (timestamp) => {
if (!timestamp) return '--:--';
const date = new Date(timestamp * 1000);
- return date.toLocaleTimeString('en-US', {
+ return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
- hour12: true
+ hour12: true,
+ timeZone: OFFICE_TZ
});
};
@@ -303,7 +307,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
{forecast.map((day, index) => {
const forecastTime = new Date(day.dt * 1000);
- const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
+ const isNight = officeHour(forecastTime) >= 18 || officeHour(forecastTime) < 6;
return (
{
= 18 || datetime.getHours() < 6
+ officeHour(datetime) >= 18 || officeHour(datetime) < 6
),
"flex items-center justify-center cursor-pointer hover:brightness-110 hover:ring-white/[0.12] transition-all relative backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"
)}>
diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx
index 636bf08..65c6091 100644
--- a/inventory/src/components/dashboard/FinancialOverview.tsx
+++ b/inventory/src/components/dashboard/FinancialOverview.tsx
@@ -43,6 +43,7 @@ import PeriodSelectionPopover, {
} from "@/components/dashboard/PeriodSelectionPopover";
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
+import { toDateOnly } from "@/utils/businessTime";
import {
DashboardSectionHeader,
DashboardStatCard,
@@ -759,8 +760,11 @@ const FinancialOverview = () => {
return;
}
params.timeRange = "custom";
- params.startDate = requestRange.start.toISOString();
- params.endDate = requestRange.end.toISOString();
+ // Date-only strings = business dates; the server resolves them to
+ // business-day bounds (1am ET / Chicago midnight). Never send
+ // browser-local-midnight instants.
+ params.startDate = toDateOnly(requestRange.start);
+ params.endDate = toDateOnly(requestRange.end);
}
const response = (await acotService.getFinancials(params)) as FinancialResponse;
diff --git a/inventory/src/components/dashboard/KlaviyoCampaigns.jsx b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx
index c6766a0..170eeab 100644
--- a/inventory/src/components/dashboard/KlaviyoCampaigns.jsx
+++ b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx
@@ -8,15 +8,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DateTime } from "luxon";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
import { Button } from "@/components/ui/button";
-import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
@@ -358,18 +351,7 @@ const KlaviyoCampaigns = ({ className }) => {
}
timeSelector={
-
-
-
-
-
- {TIME_RANGES.map((option) => (
-
- {option.label}
-
- ))}
-
-
+
}
/>
diff --git a/inventory/src/components/dashboard/OperationsMetrics.tsx b/inventory/src/components/dashboard/OperationsMetrics.tsx
index 56852ff..2636faa 100644
--- a/inventory/src/components/dashboard/OperationsMetrics.tsx
+++ b/inventory/src/components/dashboard/OperationsMetrics.tsx
@@ -47,6 +47,7 @@ import {
METRIC_COLORS,
} from "@/components/dashboard/shared";
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { toDateOnly } from "@/utils/businessTime";
type ComparisonValue = {
absolute: number | null;
@@ -335,8 +336,11 @@ const OperationsMetrics = () => {
return;
}
params.timeRange = "custom";
- params.startDate = requestRange.start.toISOString();
- params.endDate = requestRange.end.toISOString();
+ // Date-only strings = business dates; the server resolves them to
+ // business-day bounds (1am ET / Chicago midnight). Never send
+ // browser-local-midnight instants.
+ params.startDate = toDateOnly(requestRange.start);
+ params.endDate = toDateOnly(requestRange.end);
}
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
diff --git a/inventory/src/components/dashboard/ProductGrid.jsx b/inventory/src/components/dashboard/ProductGrid.jsx
index 5c67a2e..1ecce7c 100644
--- a/inventory/src/components/dashboard/ProductGrid.jsx
+++ b/inventory/src/components/dashboard/ProductGrid.jsx
@@ -12,16 +12,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { TIME_RANGES } from "@/lib/dashboard/constants";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -235,21 +228,10 @@ const ProductGrid = ({
)}
-
-
-
-
-
- {TIME_RANGES.map((range) => (
-
- {range.label}
-
- ))}
-
-
+ />
{isSearchVisible && !error && (
diff --git a/inventory/src/components/dashboard/SalesChart.jsx b/inventory/src/components/dashboard/SalesChart.jsx
index 0394dc7..df932a1 100644
--- a/inventory/src/components/dashboard/SalesChart.jsx
+++ b/inventory/src/components/dashboard/SalesChart.jsx
@@ -1,13 +1,7 @@
import React, { useState, useEffect, useCallback, memo } from "react";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
import { Button } from "@/components/ui/button";
import { TrendingUp } from "lucide-react";
import {
@@ -21,7 +15,6 @@ import {
Legend,
ReferenceLine,
} from "recharts";
-import { TIME_RANGES } from "@/lib/dashboard/constants";
import {
Table,
TableHeader,
@@ -463,21 +456,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
// Time selector for DashboardSectionHeader
const timeSelector = (
-
-
-
-
-
- {TIME_RANGES.map((range) => (
-
- {range.label}
-
- ))}
-
-
+ />
);
// Actions (Details dialog) for DashboardSectionHeader
diff --git a/inventory/src/components/dashboard/StatCards.jsx b/inventory/src/components/dashboard/StatCards.jsx
index a1a29b7..9e45b51 100644
--- a/inventory/src/components/dashboard/StatCards.jsx
+++ b/inventory/src/components/dashboard/StatCards.jsx
@@ -7,13 +7,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
import {
Dialog,
DialogContent,
@@ -49,7 +43,6 @@ import {
MapPin,
} from "lucide-react";
import { DateTime } from "luxon";
-import { TIME_RANGES } from "@/lib/dashboard/constants";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
@@ -1475,18 +1468,7 @@ const StatCards = ({
Last updated: {lastUpdate.toFormat("hh:mm a")}
)}
-
-
-
-
-
- {TIME_RANGES.map((range) => (
-
- {range.label}
-
- ))}
-
-
+
@@ -1547,18 +1529,7 @@ const StatCards = ({
-
-
-
-
-
- {TIME_RANGES.map((range) => (
-
- {range.label}
-
- ))}
-
-
+
diff --git a/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx b/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx
new file mode 100644
index 0000000..ab56f51
--- /dev/null
+++ b/inventory/src/components/dashboard/shared/BusinessRangeSelect.tsx
@@ -0,0 +1,64 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { TIME_RANGES } from "@/lib/dashboard/constants";
+import { cn } from "@/lib/utils";
+
+/**
+ * BusinessRangeSelect — the single shared named-range preset selector for
+ * dashboards. Renders the canonical TIME_RANGES presets (Today / Yesterday /
+ * Last 7|30|90 Days / This|Last Week / This|Last Month).
+ *
+ * The selected `value` is a *named range* the server resolves to business-day
+ * (1am ET) bounds — the client sends the name, never a browser-local
+ * toISOString(). See inventory-server/docs/TIME.md.
+ *
+ * Replaces the duplicated -over-TIME_RANGES blocks in StatCards,
+ * SalesChart, ProductGrid and KlaviyoCampaigns.
+ */
+
+export interface RangeOption {
+ value: string;
+ label: string;
+}
+
+export interface BusinessRangeSelectProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ /** Preset options. Defaults to the canonical TIME_RANGES list. */
+ options?: RangeOption[];
+ /** Merged onto the SelectTrigger (default sizing is w-[130px] h-9). */
+ className?: string;
+ placeholder?: string;
+ id?: string;
+}
+
+export function BusinessRangeSelect({
+ value,
+ onValueChange,
+ options = TIME_RANGES as RangeOption[],
+ className,
+ placeholder = "Select time range",
+ id,
+}: BusinessRangeSelectProps) {
+ return (
+
+
+
+
+
+ {options.map((range) => (
+
+ {range.label}
+
+ ))}
+
+
+ );
+}
+
+export default BusinessRangeSelect;
diff --git a/inventory/src/components/dashboard/shared/HorizonTabs.tsx b/inventory/src/components/dashboard/shared/HorizonTabs.tsx
new file mode 100644
index 0000000..be8fd9d
--- /dev/null
+++ b/inventory/src/components/dashboard/shared/HorizonTabs.tsx
@@ -0,0 +1,60 @@
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+/**
+ * HorizonTabs — the single shared "trailing window" selector for analytics and
+ * overview cards. The value is a number of days; the server interprets it as a
+ * business-day window ending today (see inventory-server/docs/TIME.md).
+ *
+ * Replaces the per-card duplication this consolidated:
+ * - SalesMetrics (shadcn , 7 / 30 / 90)
+ * - InventoryFlow (raw button-group, 30 / 90)
+ * - InventoryTrends (raw button-group, 30 / 90 / 365)
+ * - InventoryValueTrend(raw button-group, 30 / 90 / 365)
+ *
+ * Note: ForecastMetrics keeps its own selector — its third option ("EOY") is a
+ * forward, calendar-anchored forecast horizon, not a trailing window.
+ */
+
+const HORIZON_LABELS: Record = {
+ 7: "7D",
+ 14: "14D",
+ 30: "30D",
+ 90: "90D",
+ 365: "1Y",
+};
+
+export interface HorizonTabsProps {
+ /** Currently selected trailing window, in days. */
+ value: number;
+ /** Called with the newly selected number of days. */
+ onChange: (days: number) => void;
+ /** Day options to render, left → right. Defaults to the full set. */
+ options?: number[];
+ /** Optional className forwarded to the Tabs root. */
+ className?: string;
+}
+
+export function HorizonTabs({
+ value,
+ onChange,
+ options = [7, 30, 90, 365],
+ className,
+}: HorizonTabsProps) {
+ return (
+ onChange(Number(v))}
+ className={className}
+ >
+
+ {options.map((days) => (
+
+ {HORIZON_LABELS[days] ?? `${days}D`}
+
+ ))}
+
+
+ );
+}
+
+export default HorizonTabs;
diff --git a/inventory/src/components/forecasting/DateRangePickerQuick.tsx b/inventory/src/components/forecasting/DateRangePickerQuick.tsx
index dbb36d1..11ae4e3 100644
--- a/inventory/src/components/forecasting/DateRangePickerQuick.tsx
+++ b/inventory/src/components/forecasting/DateRangePickerQuick.tsx
@@ -55,16 +55,16 @@ export function DateRangePickerQuick({ value, onChange, className }: DateRangePi
/>
onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}>
- Last Month
+ Trailing Month
onChange({ from: addDays(addMonths(new Date(), -3), 1), to: new Date() })}>
- Last 3 Months
+ Trailing 3 Months
onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}>
- Last 6 Months
+ Trailing 6 Months
onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}>
- Last Year
+ Trailing 12 Months
diff --git a/inventory/src/components/overview/SalesMetrics.tsx b/inventory/src/components/overview/SalesMetrics.tsx
index e30fa25..57473e0 100644
--- a/inventory/src/components/overview/SalesMetrics.tsx
+++ b/inventory/src/components/overview/SalesMetrics.tsx
@@ -7,7 +7,7 @@ import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { HorizonTabs } from "@/components/dashboard/shared/HorizonTabs"
import { addDays, format } from "date-fns"
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"
@@ -74,13 +74,7 @@ export function SalesMetrics() {
<>
Sales
- setPeriod(Number(v) as Period)}>
-
- 7D
- 30D
- 90D
-
-
+ setPeriod(d as Period)} options={[7, 30, 90]} />
{isError ? (
diff --git a/inventory/src/components/ui/date-range-picker-narrow.tsx b/inventory/src/components/ui/date-range-picker-narrow.tsx
deleted file mode 100644
index 10162fb..0000000
--- a/inventory/src/components/ui/date-range-picker-narrow.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { format, addDays, startOfYear, endOfYear, subDays } from "date-fns";
-import { Calendar as CalendarIcon } from "lucide-react";
-import { DateRange } from "react-day-picker";
-import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-import { Calendar } from "@/components/ui/calendar";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-
-interface DateRangePickerProps {
- value: DateRange;
- onChange: (range: DateRange | undefined) => void;
- className?: string;
- future?: boolean;
-}
-
-export function DateRangePicker({
- value,
- onChange,
- className,
- future = false,
-}: DateRangePickerProps) {
- const today = new Date();
-
- const presets = future ? [
- {
- label: "Next 30 days",
- range: {
- from: today,
- to: addDays(today, 30),
- },
- },
- {
- label: "Next 90 days",
- range: {
- from: today,
- to: addDays(today, 90),
- },
- },
- {
- label: "Rest of year",
- range: {
- from: today,
- to: endOfYear(today),
- },
- },
- ] : [
- {
- label: "Last 7 days",
- range: {
- from: subDays(today, 7),
- to: today,
- },
- },
- {
- label: "Last 30 days",
- range: {
- from: subDays(today, 30),
- to: today,
- },
- },
- {
- label: "Last 90 days",
- range: {
- from: subDays(today, 90),
- to: today,
- },
- },
- {
- label: "Year to date",
- range: {
- from: startOfYear(today),
- to: today,
- },
- },
- ];
-
- return (
-
-
-
-
-
- {value?.from ? (
- value.to ? (
- <>
- {format(value.from, "LLL d, y")} -{" "}
- {format(value.to, "LLL d, y")}
- >
- ) : (
- format(value.from, "LLL dd, y")
- )
- ) : (
- Pick a date range
- )}
-
-
-
-
- {presets.map((preset) => (
- onChange(preset.range)}
- >
- {preset.label}
-
- ))}
-
- {
- if (range) onChange(range);
- }}
- numberOfMonths={2}
- />
-
-
-
- );
-}
\ No newline at end of file
diff --git a/inventory/src/pages/BlackFridayDashboard.tsx b/inventory/src/pages/BlackFridayDashboard.tsx
index ad8911e..26e8993 100644
--- a/inventory/src/pages/BlackFridayDashboard.tsx
+++ b/inventory/src/pages/BlackFridayDashboard.tsx
@@ -49,6 +49,7 @@ import { acotService } from "@/services/dashboard/acotService";
import { cn } from "@/lib/utils";
import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import { DateTime } from "luxon";
+import { businessTodayStr } from "@/utils/businessTime";
type BlackFridayRange = {
start: DateTime;
@@ -513,8 +514,12 @@ function LoadingBlock() {
}
export function BlackFridayDashboard() {
- const currentYear = new Date().getUTCFullYear();
- const currentMonth = new Date().getUTCMonth() + 1; // getUTCMonth() returns 0-11, so add 1
+ // Anchor on the BUSINESS date (Chicago calendar), not UTC.
+ const [businessYear, businessMonth] = businessTodayStr()
+ .split("-")
+ .map(Number);
+ const currentYear = businessYear;
+ const currentMonth = businessMonth;
// Show previous year until November of current year
const displayYear = currentMonth < 11 ? currentYear - 1 : currentYear;
const availableYears = useMemo(
diff --git a/inventory/src/pages/DiscountSimulator.tsx b/inventory/src/pages/DiscountSimulator.tsx
index 530d001..8c748d7 100644
--- a/inventory/src/pages/DiscountSimulator.tsx
+++ b/inventory/src/pages/DiscountSimulator.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DateRange } from "react-day-picker";
-import { subMonths, startOfDay, endOfDay } from "date-fns";
+import { subMonths } from "date-fns";
import { useMutation, useQuery } from "@tanstack/react-query";
import { motion } from "motion/react";
@@ -20,6 +20,7 @@ import {
CogsCalculationMode,
} from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast";
+import { toDateOnly } from "@/utils/businessTime";
const DEFAULT_POINT_VALUE = 0.005;
const DEFAULT_REDEMPTION_RATE = 0.9;
@@ -78,8 +79,9 @@ export function DiscountSimulator() {
const promoDateBounds = useMemo(() => {
const { from, to } = ensureDateRange(dateRange);
return {
- startDate: startOfDay(from).toISOString(),
- endDate: endOfDay(to).toISOString(),
+ // Date-only strings = business dates; the server resolves bounds.
+ startDate: toDateOnly(from),
+ endDate: toDateOnly(to),
};
}, [dateRange]);
@@ -138,8 +140,8 @@ export function DiscountSimulator() {
return {
dateRange: {
- start: startOfDay(from).toISOString(),
- end: endOfDay(to).toISOString(),
+ start: toDateOnly(from),
+ end: toDateOnly(to),
},
filters: {
shipCountry: 'US',
@@ -271,8 +273,13 @@ export function DiscountSimulator() {
if (parsed.dateRange) {
const defaultRange = getDefaultDateRange();
- const fromValue = parsed.dateRange.start ? new Date(parsed.dateRange.start) : undefined;
- const toValue = parsed.dateRange.end ? new Date(parsed.dateRange.end) : undefined;
+ // Saved values may be date-only ('YYYY-MM-DD') or legacy ISO instants.
+ // Parse date-only as LOCAL (bare new Date() would treat it as UTC and
+ // show the previous day in US zones).
+ const parseSaved = (value: string) =>
+ /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00`) : new Date(value);
+ const fromValue = parsed.dateRange.start ? parseSaved(parsed.dateRange.start) : undefined;
+ const toValue = parsed.dateRange.end ? parseSaved(parsed.dateRange.end) : undefined;
setDateRange({
from: fromValue ?? defaultRange.from,
to: toValue ?? defaultRange.to,
@@ -348,8 +355,8 @@ export function DiscountSimulator() {
const { from, to } = ensureDateRange(dateRange);
return JSON.stringify({
dateRange: {
- start: startOfDay(from).toISOString(),
- end: endOfDay(to).toISOString(),
+ start: toDateOnly(from),
+ end: toDateOnly(to),
},
selectedPromoId: selectedPromoId ?? null,
productPromo,
diff --git a/inventory/src/pages/Forecasting.tsx b/inventory/src/pages/Forecasting.tsx
index 19bf596..92b4497 100644
--- a/inventory/src/pages/Forecasting.tsx
+++ b/inventory/src/pages/Forecasting.tsx
@@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input";
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { toDateOnly } from "@/utils/businessTime";
type GroupMode = "line" | "category" | "designer";
@@ -126,8 +127,9 @@ export default function Forecasting() {
queryFn: async () => {
const params = new URLSearchParams({
brand: selectedBrand,
- startDate: dateRange.from?.toISOString() || "",
- endDate: dateRange.to?.toISOString() || "",
+ // Date-only strings = business dates (see docs/TIME.md)
+ startDate: dateRange.from ? toDateOnly(dateRange.from) : "",
+ endDate: dateRange.to ? toDateOnly(dateRange.to) : "",
});
const response = await apiFetch(`/api/analytics/forecast-v2?${params}`);
if (!response.ok) throw new Error("Failed to fetch forecast data");
diff --git a/inventory/src/utils/businessTime.ts b/inventory/src/utils/businessTime.ts
new file mode 100644
index 0000000..22eb269
--- /dev/null
+++ b/inventory/src/utils/businessTime.ts
@@ -0,0 +1,33 @@
+// Business-time helpers (see inventory-server/docs/TIME.md).
+//
+// THE CONVENTION: a business day runs 1am Eastern → 1am Eastern, which is
+// identical to the America/Chicago calendar date. The SERVER owns all
+// boundary math — the client sends named ranges or date-only strings
+// ('YYYY-MM-DD', meaning business dates), never toISOString() of a
+// browser-local midnight.
+
+export const BUSINESS_TZ = 'America/Chicago';
+export const OFFICE_TZ = 'America/New_York';
+
+/** The current business date as 'YYYY-MM-DD' (Chicago calendar date). */
+export function businessTodayStr(): string {
+ // en-CA formats as YYYY-MM-DD
+ return new Intl.DateTimeFormat('en-CA', { timeZone: BUSINESS_TZ }).format(new Date());
+}
+
+/** Business date of an instant. */
+export function businessDateOf(date: Date): string {
+ return new Intl.DateTimeFormat('en-CA', { timeZone: BUSINESS_TZ }).format(date);
+}
+
+/**
+ * Calendar date of a browser-local Date, as 'YYYY-MM-DD' — for converting a
+ * locally-constructed period boundary (e.g. "March 2026" → Mar 1 local) into
+ * the date-only string the server resolves to business-day bounds.
+ */
+export function toDateOnly(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo
index 09a96d3..ebb630f 100644
--- a/inventory/tsconfig.tsbuildinfo
+++ b/inventory/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/mappingsignature.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/businessrangeselect.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/horizontabs.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/mappingsignature.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/businesstime.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"}
\ No newline at end of file