Time unification

This commit is contained in:
2026-06-17 15:06:38 -04:00
parent 069a44bd54
commit 54a2460eac
67 changed files with 1442 additions and 1329 deletions
+4
View File
@@ -50,6 +50,10 @@ dashboard-server/meta-server/._package-lock.json
dashboard-server/meta-server/._services dashboard-server/meta-server/._services
*.tsbuildinfo *.tsbuildinfo
# Python bytecode
__pycache__/
*.pyc
uploads/* uploads/*
uploads/**/* uploads/**/*
**/uploads/* **/uploads/*
+217
View File
@@ -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 16 code implemented. Shared
> module at `inventory-server/shared/business-time/` (+ frontend mirror
> `inventory/src/utils/businessTime.ts`); both duplicate utils deleted;
> acot-server hour-late bug fixed; `dateStrings: true` on all mysql2 configs
> except the import pipeline (dynamic Chicago offset instead);
> `forecast_engine.py` anchors on `business_today()`; pg pools pin
> `TimeZone=America/Chicago`; `/var/www/ecosystem.config.cjs` has
> `TZ: 'America/Chicago'` (backup at `~/ecosystem.config.cjs.bak.2026-06-12`);
> supervised import+metrics cycle ran clean. Docs at
> `inventory-server/docs/TIME.md`.
>
> **Still pending (Matt):** ① `sudo -u postgres psql -c "ALTER DATABASE
> inventory_db SET timezone = 'America/Chicago';"` ② `pm2 reload
> ecosystem.config.cjs --update-env` (mind the JWT_SECRET shell-var gotcha)
> ③ flush dashboard Redis ④ frontend `npm run build` (deploys) ⑤ Phase 2
> before/after verification + Klaviyo UTC spot-check. Deferred (non-correctness):
> GA4/Meta footnote labels. (UPDATE 2026-06-13: the two-selector-kit UI
> consolidation — `BusinessRangeSelect` + `HorizonTabs` — is now implemented; see Phase 5.)
## The Convention (single source of truth)
**A business day runs 1:00:00am Eastern → 12:59:59am Eastern the next day.**
Because 1am Eastern == midnight Central year-round (both observe US DST), this is identical to:
> **Business date = the America/Chicago calendar date.**
Practical consequences — these are the ONLY approved idioms:
| Context | Correct idiom |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| PG `timestamptz` → business date | `(ts AT TIME ZONE 'America/Chicago')::date` — or plain `ts::date` / `CURRENT_DATE` once Phase 1 sets the session TZ |
| MySQL DATETIME literal (stores Central wall-clock) → business date | `DATE(col)`**no hour shift** |
| MySQL WHERE bounds for a business day | Central wall-clock strings: `col >= 'YYYY-MM-DD 00:00:00' AND col < 'YYYY-MM-DD+1 00:00:00'` |
| Luxon | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset |
| Intervals | **Half-open** `[start, nextStart)`. No `-1 minute`, no `-1 ms` endpoints |
| "Today" in JS | a `businessToday()` helper (Chicago date), never `new Date().toISOString().split('T')[0]` (UTC) and never bare `CURRENT_DATE` reasoning in Node |
## Verified facts (2026-06-11/12)
- MySQL (192.168.1.5) session/global TZ = SYSTEM = America/Chicago. `NOW()` = UTC-5 (CDT) ✓ live-checked.
- `_order.date_placed`, `_order.date_shipped` are DATETIME storing **Central wall-clock** ✓ (recent order: `date_placed 18:28` at 23:45 UTC).
- `timeclock.TimeStamp` stores **Central wall-clock** ✓ (8am-ET punch-ins recorded as ~07:0307:06; `NOW()` = 08:14 at ~9:14am ET).
- `picking_ticket.createddate` — PHP-written, Central (same as all PHP DATETIME writes; spot-check during Phase 2 anyway).
- PostgreSQL `inventory_db` session TZ = **Europe/Berlin** ✓ (`SHOW timezone`).
- Node processes on netcup run with system TZ Europe/Berlin (no `TZ` in `.env`/ecosystem).
- Browsers/office = America/New_York.
- GA4 property reporting TZ = America/New_York (per Matt); API `NdaysAgo` resolves in property TZ.
- Klaviyo account TZ = Eastern; API documented to return UTC — **verify in Phase 5** (one event spot-check).
- PG columns: `orders.date`, `receivings.received_date`, `purchase_orders.date`, `products.created_at/date_online`, `product_metrics.last_calculated` = timestamptz; `stock_snapshots.snapshot_date`, `daily_product_snapshots.snapshot_date`, `purchase_orders.expected_date`, `product_forecasts.forecast_date` = DATE.
## Known defects this plan fixes
1. **acot-server dashboard windows are 1 hour late** (today = 2am2am ET): `toDatabaseSqlString()` in `dashboard/acot-server/utils/timeUtils.js` passes Eastern wall-clock (via `keepLocalTime: true` + fixed `UTC-05:00`) to a Central-literal DB.
2. **Fixed `UTC-05:00` offsets** in `timeUtils.js` (`DB_TIMEZONE`) and `events.js:639` — wrong every winter (CST = UTC-6).
3. `**DATE_SUB(col, INTERVAL 1 HOUR)` daily bucketing** in operations/employee metrics — assumed Eastern storage; columns are Central, so buckets are also 1h late.
4. **payroll-metrics punch parsing**: mysql2 `timezone: 'Z'` turns `07:58` Central into `07:58Z`, then luxon renders it as 03:58 ET — punches shifted 5h before pay-period math; overnight punches (midnight5am CT) land on the wrong day.
5. **PG routes use Berlin "today"**: every `CURRENT_DATE` / `NOW()` / `::date` / `DATE(col)` in `src/routes/`* and the window anchors in `scripts/metrics-new/*.sql` flip to the next business day at 6pm ET.
6. **JS UTC "today"** (`new Date().toISOString().split('T')[0]`) in `src/routes/dashboard.js` and others flips at 8pm ET — a third clock.
7. **DATE-column serialization off-by-one**: node-postgres returns `DATE` as a JS Date at *Berlin local midnight* (= 22:00/23:00 UTC the previous day); `d.toISOString().split('T')[0]` then emits the **previous day**. Affects `src/routes/dashboard.js` (~lines 335, 379, 478, 542, 779, 799, 812) and `src/routes/products.js:1006` wherever the value is a true DATE column.
8. **Two duplicate luxon utils disagree**: `TimeManager.getDayEnd` = next 1am 1 *minute* (59.999s hole); `timeUtils.getDayEnd` = 1 ms. `TimeManager.groupEventsByInterval` groups by `startOf('day')` in the event string's own zone — neither ET nor the 1am rule.
9. **Routes that ignore the convention**: `discounts.js` (zoneless `DateTime.fromISO` + `startOf('day')` = Berlin midnight), `customers.js` (MySQL `NOW()` windows), `events.js` hourly chart (Central hour labels shown to an Eastern office).
10. **Three mysql2 timezone configs for the same DB**: import `'-05:00'` (+ dynamic outbound fix), acot-server `'Z'`, `src/utils/dbConnection.js` + `src/routes/import.js` `'Z'`.
11. **Frontend**: custom ranges are browser-local midnight→23:59:59 (not business days); Forecasting's "Last Month" preset = *trailing* month while everywhere else it's *calendar* month; BlackFridayDashboard does UTC date math; 5 different range-option vocabularies; `date-range-picker-narrow.tsx` is dead code.
---
## Phase 1 — PostgreSQL & Node clocks (highest leverage, smallest diff)
**1.1 Set PG database timezone to business time.**
```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` (~~184214) and `routes/employee-metrics.js` (~~324351): `DATE_FORMAT(DATE_SUB(col, INTERVAL 1 HOUR), '%Y-%m-%d')``DATE(col)` for `pt.createddate`, `o.date_shipped`, `tc.TimeStamp` (all Central literals — verified). Update the misleading "Eastern timezone" comments.
- [ ] Where the JS then re-parses those date strings (`DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE })`) keep zone ET only for *labeling*; the string itself is already the business date — pass it through.
**2.3 `routes/events.js`:**
- [ ] Line ~639: `DateTime.fromSQL(value, { zone: 'UTC-05:00' })``{ zone: 'America/Chicago' }` (or route through `parseBusinessDate`).
- [ ] Hourly chart (today/yesterday): currently `HOUR(date_placed)` returns **Central** hours; office reads the axis as Eastern. Convert for display: emit the business timestamp and format hours in ET on the frontend, or `HOUR(CONVERT_TZ(date_placed,'America/Chicago','America/New_York'))` if the MySQL tz tables are loaded (verify with `SELECT CONVERT_TZ(NOW(),'America/Chicago','America/New_York')`; fall back to `ADDTIME(date_placed, '01:00:00')` — ET = CT + 1h always). Same for the `currentHour` source.
- [ ] Previous-period custom math (~553561) is instant-math and stays as-is once bounds are correct.
**2.4 Routes not on the util:**
- [ ] `routes/discounts.js` (~6285): replace zoneless `DateTime.fromISO` + Berlin `startOf('day')`/`endOf('day')` with `getTimeRangeConditions('custom', …)` / business-day helpers.
- [ ] `routes/customers.js` (~238244): `DATE_SUB(NOW(), INTERVAL 3 MONTH)` is a rough trailing window — acceptable; add a comment that `NOW()` here is Central and that's intended. (Or switch to bound params from the util for uniformity.)
- [ ] `routes/payroll-metrics.js`: punches must be interpreted as Central. Either (preferred, see 2.5) get strings from the driver and parse with `DateTime.fromSQL(s, { zone: 'America/Chicago' })`, or correct the `'Z'`-mangled Dates explicitly. All pay-period boundary math stays in ET — that's display convention; the parse is what's broken. Re-test: a 7:58am-CT punch must render 8:58am ET and land on the right pay-period day; test an overnight punch (e.g. 00:30 CT).
**2.5 Driver config — make values unambiguous:**
- [ ] `db/connection.js`: add `dateStrings: true` (and drop `timezone: 'Z'`). All DATETIME/TIMESTAMP values arrive as literal strings; every consumer parses through the shared helper with zone Chicago. This converts the implicit-zone footgun into a compile-time-visible parse step. Sweep acot-server routes for `instanceof Date` assumptions and `new Date(row.X)` on datetime fields and route them through the parser.
**Verification (Phase 2):**
- [ ] Live-DB boundary check: place/find an order with `date_placed` between `00:00` and `01:00` CT (i.e. 12am ET); `timeRange=today` must include orders from `00:00:00` CT onward. Before the fix it starts at `01:00:00` CT.
- [ ] Compare `/stats?timeRange=yesterday` totals before/after; expect ~the 1am2am ET hour to migrate between days.
- [ ] Employee daily hours for a known day vs the punch list by hand.
- [ ] Flush the dashboard Redis cache after deploy (cached responses are keyed by `timeRange` and hold pre-fix numbers).
## Phase 3 — One shared business-time module (kill the duplicate utils)
Create `inventory-server/shared/business-time/` (consistent with the shared-lib location and the side-service convention — this is a leaf dependency, not a middle layer). Export, roughly:
```
BUSINESS_TZ = 'America/Chicago' // canonical
OFFICE_TZ = 'America/New_York' // display only
businessToday() // Chicago calendar date (DateTime)
businessDayStart(d) / businessDayRange(d) // [start, nextStart)
rangeForPreset(name, now?) // today|yesterday|last7days|...|lastMonth — one vocabulary
previousRange(name|range)
toMySqlBound(dt) // Central wall-clock 'YYYY-MM-DD HH:mm:ss'
parseMySql(s) // zone Chicago → DateTime
businessDateOf(instant) // → 'YYYY-MM-DD'
formatOffice(dt, fmt) // ET display formatting
```
- [ ] Port `timeUtils.js` consumers (events/operations/employee/discounts/payroll) to it; delete `dashboard/acot-server/utils/timeUtils.js`.
- [ ] Port `TimeManager` consumers (klaviyo routes + `events.service.js`, `campaigns.service.js`, `reporting.service.js`, `redis.service.js`) to it; delete `dashboard/utils/time.utils.js`. Klaviyo API bounds stay UTC ISO (that part was right).
- [ ] Fix `groupEventsByInterval` while porting: group by `businessDateOf(eventInstant)` (day) and `setZone(OFFICE_TZ)` for hour/week/month — never the event string's own offset.
- [ ] One range vocabulary: the union of today's two sets — `today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days, previous7/30/90days, custom`. Both servers accept exactly this list; unknown → 400, not a silent default.
- [ ] ESM/CJS: dashboard tree is ESM, `src/` is CJS — publish the module dual (or `.mjs` with a tiny CJS wrapper), same pattern as existing shared lib.
- [ ] Klaviyo verification (per Matt): pull one known recent order event and confirm `attributes.datetime` is UTC (`Z`/+00:00) and matches the PG `orders.date` instant. If it ever arrives as ET-naive, add an explicit zone at the single parse point in the module.
## Phase 4 — inventory-server `src/routes` cleanup (mostly mechanical after Phase 1)
- [ ] `dashboard.js`: replace `new Date()`/`toISOString().split('T')[0]` "today"/default-range derivations (~309316, 10411043, 11151120) with `businessToday()` / date-only strings; emit DATE columns via `::text` (lines ~335, 379, 478, 542, 769815).
- [ ] `analytics.js`: default ranges (~1924, 180184) — drop the UTC truncation dance; accept `YYYY-MM-DD` business dates, default via `businessToday()`. `o.date::date` joins become business-correct after Phase 1 — change to the explicit `AT TIME ZONE 'America/Chicago'` idiom anyway in the hot endpoints so they don't regress if session TZ ever changes.
- [ ] `orders.js`: `fromDate/toDate` (~~1345) — treat date-only input as business-day bounds (start of `from`'s business day to start of day after `to`), not UTC midnights; `DATE(date)` windows (~~115131) fine post-Phase 1.
- [ ] `products.js:1006`, `purchase-orders.js:719/772` (UTC-today defaults), `repeat-orders.js`, `newsletter.js`: same two substitutions (business today; `::text` for DATE output). All `CURRENT_DATE` usages are correct after Phase 1 — leave them.
- [ ] `metrics-new/*.sql`: window anchors (`CURRENT_DATE - INTERVAL '59 days'` etc., `update_product_metrics.sql:11` `_current_date`) become business-correct via Phase 1 — no edits required; add a header comment to each file stating the session-TZ assumption.
- [ ] Standardize `src/utils/dbConnection.js` and the inline config in `src/routes/import.js` to `dateStrings: true` + shared parsing (they currently use `timezone: 'Z'`; consumers are product-text-centric so risk is low, but one convention).
- [ ] Import pipeline (`scripts/import/`): keep `adjustDateForMySQL()` for outbound params (it's the correct dynamic approach) — or migrate to `dateStrings: true` + `toMySqlBound()` for symmetry. Either way, inbound DATETIME *reads* must stop being parsed at fixed `-05:00`: audit `purchase-orders.js`/`daily-deals.js` for fields that survive into PG as instants and route them through `parseMySql()`. (`orders` uses `stamp` TIMESTAMP — wire-format follows session tz = Central; same parse rule applies.)
## Phase 5 — Frontend consolidation
**Principle: the server owns all boundary math.** The client sends either a named range or date-only strings (`YYYY-MM-DD`, meaning business dates); it never sends `toISOString()` of a browser-local midnight.
- [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 `<Select>`-over-`TIME_RANGES` blocks). Shipped preset-only: those cards never had a custom-range UI, and FinancialOverview/OperationsMetrics keep their own natural-language period pickers (boundary math already fixed earlier in Phase 5). A `Custom…` popover can be added to the shared component later if a card needs it.
2. `HorizonTabs` — trailing-day windows (`7 / 30 / 90 / 365`, label map incl. `14D`/`1Y`). Migrated SalesMetrics (`7|30|90`), InventoryTrends/ValueTrend (`30|90|365`), InventoryFlow (`30|90`). **Corrections vs. this plan's list:** GrowthMomentum has *no* period selector (nothing to migrate), and the `30|90|'year'` union is actually ForecastMetrics — left alone because its 3rd option is a forward EOY forecast horizon, not a trailing window. Each card keeps its own fetch; this was a pure presentational/DRY swap. The GA4/Meta day-granular cards (MetaCampaigns/UserBehavior/Analytics) are left for the deferred Phase 6 footnote pass.
- [ ] `FinancialOverview` / `OperationsMetrics` custom periods: send the parsed period descriptor (`{type, startYear, startMonth|startQuarter, count}` from `naturalLanguagePeriod`) or derived date-only strings — not browser-midnight ISO instants (currently `setHours(0,0,0,0)`/`23:59:59.999` + `toISOString()`, FinancialOverview ~lines 560620, 762763). Server resolves to 1am-ET bounds. Client-side bucket alignment (`setHours(0,…)` ~390/432) should key on server-provided business-date strings instead.
- [ ] `MetaCampaigns` / `UserBehaviorDashboard` / `AnalyticsDashboard`: keep numeric `7/14/30/90` where the upstream API is day-granular (GA4/Meta), but render through `HorizonTabs` for visual consistency; label the GA/Meta cards' footnote "calendar days, midnight ET" (platform limitation — accepted deviation, see Phase 6).
- [ ] Forecasting `DateRangePickerQuick`: rename presets to "Trailing month / 3 / 6 / 12 months" (they are `addMonths(now, -N)`, not calendar months) or convert to calendar months — decide one; today the label collides with the dashboards' calendar "Last Month".
- [ ] Delete `src/components/ui/date-range-picker-narrow.tsx` (zero importers).
- [ ] `BlackFridayDashboard`: replace `getUTCFullYear/getUTCMonth` anchors with business-date helpers (a small `businessToday()` util in `src/utils/` — Chicago date via `Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' })`).
- [ ] `lib/dashboard/constants.js`: keep `TIME_RANGES` as the one preset list; `formatDateForInput`/`parseDateFromInput` stay (datetime-local is inherently browser-local) but custom values must be converted to date-only/business params at the request boundary, not `toISOString()`.
- [ ] `DateTime.jsx` (dashboard clock): browser-local is fine for a wall clock; pin to `America/New_York` via `toLocaleString` options so a remote viewer sees office time.
## Phase 6 — External services: document accepted deviations
These platforms bucket at **midnight ET**, not 1am ET. The 1-hour difference is not worth fighting their APIs; record it instead.
- [ ] GA4: property TZ = America/New_York; `NdaysAgo` = calendar days midnight ET. Accepted deviation (affects 0:001:00am ET traffic attribution only).
- [ ] Meta insights: `time_zone: 'America/New_York'` — same deviation, same note.
- [ ] Klaviyo: data fetched with exact UTC bounds from our own range math → fully on-convention once Phase 3 lands (plus the UTC-return verification).
- [ ] Add a `docs/TIME.md` (or a section in `.claude/CLAUDE.md`) stating: the convention, the approved idioms table, the accepted deviations, and "never hand-roll an hour offset."
## Rollout order & risk notes
1. Phase 2 (acot-server fix) is independent of everything — ship first; it corrects the most-watched numbers. Flush Redis after deploy.
2. Phase 1 next, off-hours (ideally before 6pm ET so Berlin/Chicago dates agree during the change); pause the auto-update (`touch inventory-server/.pause-auto-update`), apply, run one supervised `full-update.js`, compare metrics row counts, unpause.
3. Phases 34 together (module + ports), then 5, then 6.
4. PM2: env changes need `pm2 reload ecosystem.config.cjs --update-env` (restart won't pick up new env, except dashboard-server which loads dotenv itself).
5. DST regression dates for tests: **Nov 1 2026** (fall back) and **Mar 14 2027** (spring forward) — boundary tests for `rangeForPreset('today')`, `toMySqlBound`, and the metrics windows on both.
6. Expect small visible shifts in historical dashboard numbers after Phase 2 (the 1am2am ET hour moves to its correct day) and after Phase 1 (evening-run metrics windows stop drifting). Worth a heads-up to anyone comparing screenshots.
## Decisions needed (Matt)
- [ ] Forecasting quick-presets: trailing (rename) or calendar months? trailing
- [ ] Hourly "today" chart axis: display Eastern hours (recommended) — confirm. - yes
- [ ] Custom calendar periods (e.g. "March 2026") = business month (Mar 1 1am ET → Apr 1 1am ET, recommended) — confirm, so FinancialOverview presets and custom agree. yes
@@ -211,7 +211,12 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD, password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME, database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306, 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) => { return new Promise((resolve, reject) => {
@@ -230,6 +230,8 @@ router.get('/:cid/orders', async (req, res) => {
try { try {
// MySQL-safe equivalent of the Laravel query in the freescout module. // MySQL-safe equivalent of the Laravel query in the freescout module.
// Active = placed OR shipped within the last 3 months. // 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( const [ordersRaw] = await connection.execute(
`SELECT order_id, order_status, order_type, summary_total, `SELECT order_id, order_status, order_type, summary_total,
date_placed, ship_method_type, ship_method_tracking, date_placed, ship_method_type, ship_method_tracking,
@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getDbConnection } from '../db/connection.js'; import { getDbConnection } from '../db/connection.js';
import { BUSINESS_TZ, businessNow, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
@@ -55,20 +56,20 @@ const DEFAULTS = {
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, 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) { function parseDate(value, fallback) {
if (!value) { if (!value) {
return fallback; return fallback;
} }
const parsed = DateTime.fromISO(value); const parsed = DateTime.fromISO(value, { zone: BUSINESS_TZ });
if (!parsed.isValid) { if (!parsed.isValid) {
return fallback; return fallback;
} }
return parsed; return parsed;
} }
function formatDateForSql(dt) { const formatDateForSql = (dt) => toMySqlBound(dt);
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
}
router.get('/promos', async (req, res) => { router.get('/promos', async (req, res) => {
let connection; let connection;
@@ -78,11 +79,12 @@ router.get('/promos', async (req, res) => {
const releaseConnection = release; const releaseConnection = release;
const { startDate, endDate } = req.query || {}; 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 defaultStart = now.minus({ years: 3 }).startOf('day');
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart; 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 rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart; const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
@@ -110,7 +112,7 @@ router.get('/promos', async (req, res) => {
) u ON u.discount_code = p.promo_id ) u ON u.discount_code = p.promo_id
WHERE p.date_start IS NOT NULL WHERE p.date_start IS NOT NULL
AND p.date_end 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.store = 1
AND p.date_start >= '2010-01-01' AND p.date_start >= '2010-01-01'
ORDER BY p.promo_id DESC ORDER BY p.promo_id DESC
@@ -343,10 +345,11 @@ router.post('/simulate', async (req, res) => {
pointsConfig = {} pointsConfig = {}
} = req.body || {}; } = 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 startDefault = endDefault.minus({ months: 6 });
const startDt = parseDate(dateRange.start, startDefault).startOf('day'); 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 shipCountry = filters.shipCountry || 'US';
const promoIds = Array.from( const promoIds = Array.from(
@@ -463,7 +466,7 @@ router.post('/simulate', async (req, res) => {
AND o.order_status >= 20 AND o.order_status >= 20
AND o.ship_method_selected <> 'holdit' AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ? AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ? AND o.date_placed >= ? AND o.date_placed < ?
${promoExistsClause} ${promoExistsClause}
`; `;
@@ -1,12 +1,25 @@
import express from 'express'; import express from 'express';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js'; 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 router = express.Router();
const TIMEZONE = 'America/New_York'; 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 // Punch types from the database
const PUNCH_TYPES = { const PUNCH_TYPES = {
OUT: 0, OUT: 0,
@@ -40,7 +53,7 @@ function calculateHoursFromPunches(punches) {
byEmployee.forEach((employeePunches, employeeId) => { byEmployee.forEach((employeePunches, employeeId) => {
// Sort by timestamp // 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 hours = 0;
let breakHours = 0; let breakHours = 0;
@@ -48,24 +61,24 @@ function calculateHoursFromPunches(punches) {
let breakStart = null; let breakStart = null;
employeePunches.forEach(punch => { employeePunches.forEach(punch => {
const punchTime = new Date(punch.TimeStamp); const punchAt = punchTime(punch);
switch (punch.PunchType) { switch (punch.PunchType) {
case PUNCH_TYPES.IN: case PUNCH_TYPES.IN:
currentIn = punchTime; currentIn = punchAt;
break; break;
case PUNCH_TYPES.OUT: case PUNCH_TYPES.OUT:
if (currentIn) { if (currentIn) {
hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours hours += (punchAt - currentIn) / (1000 * 60 * 60); // Convert ms to hours
currentIn = null; currentIn = null;
} }
break; break;
case PUNCH_TYPES.BREAK_START: case PUNCH_TYPES.BREAK_START:
breakStart = punchTime; breakStart = punchAt;
break; break;
case PUNCH_TYPES.BREAK_END: case PUNCH_TYPES.BREAK_END:
if (breakStart) { if (breakStart) {
breakHours += (punchTime - breakStart) / (1000 * 60 * 60); breakHours += (punchAt - breakStart) / (1000 * 60 * 60);
breakStart = null; breakStart = null;
} }
break; break;
@@ -315,13 +328,13 @@ router.get('/', async (req, res) => {
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24)); const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
const weeksInPeriod = periodDays / 7; const weeksInPeriod = periodDays / 7;
// Get daily trend data for hours // Get daily trend data for hours.
// Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues // TimeStamp stores Central wall-clock literals; business date = the
// Business day starts at 1 AM, so subtract 1 hour before taking the date // Central calendar date, so DATE_FORMAT(col) needs no hour shift.
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp'); const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
const trendQuery = ` const trendQuery = `
SELECT 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.EmployeeID,
tc.TimeStamp, tc.TimeStamp,
tc.PunchType tc.PunchType
@@ -337,18 +350,19 @@ router.get('/', async (req, res) => {
// Get daily picking data for trend // Get daily picking data for trend
// Ship-together orders: only count main orders (is_sub = 0 or NULL) // 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 pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = ` const pickingTrendQuery = `
SELECT 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, 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 COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere} WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL 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 ORDER BY date
`; `;
@@ -376,13 +390,14 @@ router.get('/', async (req, res) => {
byDate.get(date).push(row); 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 allDatesInRange = [];
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day'); const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day'); const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt; let currentDt = startDt;
while (currentDt <= endDt) { while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd')); allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 }); currentDt = currentDt.plus({ days: 1 });
} }
@@ -393,7 +408,8 @@ router.get('/', async (req, res) => {
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches); const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 }; 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 }); const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return { return {
@@ -646,22 +662,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null; return null;
} }
const start = new Date(startDate); try {
const end = new Date(endDate); const prev = previousRange(customRange(startDate, endDate));
if (!prev) return null;
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
} catch {
return null; 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) { function getPreviousTimeRange(timeRange) {
@@ -5,8 +5,12 @@ import {
getTimeRangeConditions, getTimeRangeConditions,
formatBusinessDate, formatBusinessDate,
getBusinessDayBounds, getBusinessDayBounds,
customRange,
previousRange,
parseMySql,
toMySqlBound,
_internal as timeHelpers, _internal as timeHelpers,
} from '../utils/timeUtils.js'; } from '../../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
@@ -83,7 +87,7 @@ router.get('/stats', async (req, res) => {
FROM _order FROM _order
WHERE order_status = 15 WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)} 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); const [cancelledResult] = await connection.execute(cancelledQuery, params);
@@ -96,7 +100,7 @@ router.get('/stats', async (req, res) => {
ABS(SUM(payment_amount)) as refundTotal ABS(SUM(payment_amount)) as refundTotal
FROM order_payment op FROM order_payment op
JOIN _order o ON op.order_id = o.order_id 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); const [refundStats] = await connection.execute(refundsQuery, params);
@@ -108,7 +112,7 @@ router.get('/stats', async (req, res) => {
FROM _order FROM _order
WHERE order_status IN (92, 95, 100) WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)} 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); const [shippedResult] = await connection.execute(shippedQuery, params);
@@ -129,29 +133,29 @@ router.get('/stats', async (req, res) => {
const [bestDayResult] = await connection.execute(bestDayQuery, params); 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; let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) { if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = ` const peakHourQuery = `
SELECT SELECT
HOUR(date_placed) as hour, HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count COUNT(*) as count
FROM _order FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} 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 ORDER BY count DESC
LIMIT 1 LIMIT 1
`; `;
const [peakHourResult] = await connection.execute(peakHourQuery, params); const [peakHourResult] = await connection.execute(peakHourQuery, params);
if (peakHourResult.length > 0) { if (peakHourResult.length > 0) {
const hour = peakHourResult[0].hour; const hour = parseInt(peakHourResult[0].hour);
const date = new Date();
date.setHours(hour, 0, 0);
peakHour = { peakHour = {
hour, hour,
count: parseInt(peakHourResult[0].count), 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] // Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
let hourlyOrders = null; let hourlyOrders = null;
if (['today', 'yesterday'].includes(timeRange)) { 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 = ` const hourlyQuery = `
SELECT SELECT
HOUR(date_placed) as hour, HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count, COUNT(*) as count,
HOUR(NOW()) as currentHour HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
FROM _order FROM _order
WHERE order_status > 15 WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)} AND ${getCherryBoxClause(excludeCB)}
AND date_placed >= NOW() - INTERVAL 24 HOUR 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); const [hourlyResult] = await connection.execute(hourlyQuery);
// Get current hour from MySQL (same timezone as the WHERE clause) // Current hour in Eastern (fallback computes it directly)
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours(); const currentHour = hourlyResult.length > 0
? parseInt(hourlyResult[0].currentHour)
: DateTime.now().setZone(TIMEZONE).hour;
// Build map of hour -> count // Build map of hour -> count
const hourCounts = {}; const hourCounts = {};
@@ -208,7 +216,7 @@ router.get('/stats', async (req, res) => {
JOIN _order o ON oi.order_id = o.order_id JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid JOIN products p ON oi.prod_pid = p.pid
JOIN product_categories pc ON p.company = pc.cat_id 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 GROUP BY pc.cat_id, pc.name
HAVING revenue > 0 HAVING revenue > 0
ORDER BY revenue DESC 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 JOIN product_categories pc ON pci.cat_id = pc.cat_id
WHERE o.order_status > 15 WHERE o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)} 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) AND pc.type IN (10, 20, 11, 21, 12, 13)
GROUP BY pc.cat_id, pc.name GROUP BY pc.cat_id, pc.name
HAVING revenue > 0 HAVING revenue > 0
@@ -253,7 +261,7 @@ router.get('/stats', async (req, res) => {
FROM _order FROM _order
WHERE order_status IN (92, 95, 100) WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)} 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 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 WHERE op.payment_amount < 0
AND o.order_status > 15 AND o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)} 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) GROUP BY DATE(op.payment_date)
ORDER BY DATE(op.payment_date) ORDER BY DATE(op.payment_date)
`; `;
@@ -457,7 +465,7 @@ router.get('/stats/details', async (req, res) => {
FROM _order FROM _order
WHERE order_status = 15 WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)} AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')} AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
GROUP BY DATE(date_cancelled) GROUP BY DATE(date_cancelled)
ORDER BY DATE(date_cancelled) ORDER BY DATE(date_cancelled)
`; `;
@@ -549,16 +557,10 @@ router.get('/stats/details', async (req, res) => {
prevWhereClause = result.whereClause; prevWhereClause = result.whereClause;
prevParams = result.params; prevParams = result.params;
} else { } else {
// Custom date range - go back by the same duration // Custom date range - go back by the same duration (business-day aware)
const start = new Date(startDate); const prev = previousRange(customRange(startDate, endDate));
const end = new Date(endDate); prevWhereClause = 'date_placed >= ? AND date_placed < ?';
const duration = end.getTime() - start.getTime(); prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
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()];
} }
// Get previous period daily data (optionally excludes Cherry Box orders) // Get previous period daily data (optionally excludes Cherry Box orders)
@@ -576,10 +578,10 @@ router.get('/stats/details', async (req, res) => {
const [prevResults] = await connection.execute(prevQuery, prevParams); const [prevResults] = await connection.execute(prevQuery, prevParams);
// Create a map for quick lookup of previous period data // Create a map for quick lookup of previous period data
// (dateStrings: true — DATE columns arrive as 'YYYY-MM-DD' strings)
const prevMap = new Map(); const prevMap = new Map();
prevResults.forEach(prev => { prevResults.forEach(prev => {
const key = new Date(prev.date).toISOString().split('T')[0]; prevMap.set(String(prev.date), prev);
prevMap.set(key, prev);
}); });
// For period-to-period comparison, we need to map days by relative position // 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) => { const formatDebugBound = (value) => {
if (!value) return 'n/a'; if (!value) return 'n/a';
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' }); const parsed = parseMySql(value);
if (!parsed.isValid) { if (!parsed) {
return `invalid(${value})`; return `invalid(${value})`;
} }
return parsed.setZone(TIMEZONE).toISO(); return parsed.setZone(TIMEZONE).toISO();
@@ -771,7 +773,7 @@ router.get('/products', async (req, res) => {
FROM order_items oi FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid 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 GROUP BY p.pid, p.description
ORDER BY totalRevenue DESC ORDER BY totalRevenue DESC
LIMIT 500 LIMIT 500
@@ -1029,15 +1031,13 @@ function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
} }
function buildFinancialTrendQuery(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 // Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) { if (excludeCherryBox) {
return ` return `
SELECT SELECT
DATE_FORMAT( DATE_FORMAT(r.date_change, '%Y-%m-%d') as businessDate,
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SUM(r.sale_amount) as grossSales, SUM(r.sale_amount) as grossSales,
SUM(r.refund_amount) as refunds, SUM(r.refund_amount) as refunds,
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees, SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
@@ -1055,10 +1055,7 @@ function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
} }
return ` return `
SELECT SELECT
DATE_FORMAT( DATE_FORMAT(date_change, '%Y-%m-%d') as businessDate,
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SUM(sale_amount) as grossSales, SUM(sale_amount) as grossSales,
SUM(refund_amount) as refunds, SUM(refund_amount) as refunds,
SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees, SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees,
@@ -1193,22 +1190,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null; return null;
} }
const start = new Date(startDate); try {
const end = new Date(endDate); const prev = previousRange(customRange(startDate, endDate));
if (!prev) return null;
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
} catch {
return null; 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) { async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
@@ -1221,16 +1209,10 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate,
prevWhereClause = result.whereClause; prevWhereClause = result.whereClause;
prevParams = result.params; prevParams = result.params;
} else { } else {
// Custom date range - go back by the same duration // Custom date range - go back by the same duration (business-day aware)
const start = new Date(startDate); const prev = previousRange(customRange(startDate, endDate));
const end = new Date(endDate); prevWhereClause = 'date_placed >= ? AND date_placed < ?';
const duration = end.getTime() - start.getTime(); prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
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()];
} }
// Previous period query (optionally excludes Cherry Box orders) // Previous period query (optionally excludes Cherry Box orders)
@@ -1,7 +1,12 @@
import express from 'express'; import express from 'express';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js'; 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(); const router = express.Router();
@@ -170,9 +175,10 @@ router.get('/', async (req, res) => {
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0; const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0; const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
// Get daily trend data for picking // Get daily trend data for picking.
// Use DATE_FORMAT to get date string in Eastern timezone // Columns store Central wall-clock literals, and the business date IS
// Business day starts at 1 AM, so subtract 1 hour before taking the date // 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 pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = ` const pickingTrendQuery = `
SELECT SELECT
@@ -181,22 +187,22 @@ router.get('/', async (req, res) => {
pt_agg.piecesPicked pt_agg.piecesPicked
FROM ( FROM (
SELECT 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 COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt FROM picking_ticket pt
WHERE ${pickingTrendWhere} WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL 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 ) pt_agg
LEFT JOIN ( LEFT JOIN (
SELECT 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 COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
FROM picking_ticket pt FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere} WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL 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_counts ON pt_agg.date = order_counts.date
ORDER BY pt_agg.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 shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
const shippingTrendQuery = ` const shippingTrendQuery = `
SELECT 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, 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 COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
FROM _order o FROM _order o
WHERE ${shippingTrendWhere} WHERE ${shippingTrendWhere}
AND o.order_status IN (100, 92) 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 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 allDatesInRange = [];
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day'); const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day'); const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt; let currentDt = startDt;
while (currentDt <= endDt) { while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd')); allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 }); currentDt = currentDt.plus({ days: 1 });
} }
@@ -255,7 +262,8 @@ router.get('/', async (req, res) => {
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 }; const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 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 }); const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return { return {
@@ -446,22 +454,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null; return null;
} }
const start = new Date(startDate); try {
const end = new Date(endDate); const prev = previousRange(customRange(startDate, endDate));
if (!prev) return null;
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
} catch {
return null; 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) { function getPreviousTimeRange(timeRange) {
@@ -1,11 +1,20 @@
import express from 'express'; import express from 'express';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js'; import { getDbConnection, getPoolStatus } from '../db/connection.js';
import { parseMySql, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
const TIMEZONE = 'America/New_York'; 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 // Punch types from the database
const PUNCH_TYPES = { const PUNCH_TYPES = {
OUT: 0, OUT: 0,
@@ -108,17 +117,17 @@ function calculateHoursByWeek(punches, payPeriod) {
byEmployee.forEach((employeeData) => { byEmployee.forEach((employeeData) => {
// Sort punches by timestamp // 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 // Calculate hours for each week
const week1Punches = employeeData.punches.filter(p => { const week1Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE }); const dt = punchDateTime(p);
return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end; return dt && dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
}); });
const week2Punches = employeeData.punches.filter(p => { const week2Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE }); const dt = punchDateTime(p);
return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end; return dt && dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
}); });
const week1Hours = calculateHoursFromPunches(week1Punches); const week1Hours = calculateHoursFromPunches(week1Punches);
@@ -205,7 +214,7 @@ function calculateHoursFromPunches(punches) {
let breakStart = null; let breakStart = null;
punches.forEach(punch => { punches.forEach(punch => {
const punchTime = new Date(punch.TimeStamp); const punchTime = punchDateTime(punch)?.toJSDate() ?? new Date(NaN);
switch (punch.PunchType) { switch (punch.PunchType) {
case PUNCH_TYPES.IN: 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`); console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
try { try {
// Build query for the pay period // Build query for the pay period. Bounds are serialized to Central
const periodStart = payPeriod.start.toJSDate(); // wall-clock strings (the column's literal zone), half-open
const periodEnd = payPeriod.end.toJSDate(); // [start, start + 14 days).
const periodStart = toMySqlBound(payPeriod.start);
const periodEnd = toMySqlBound(payPeriod.start.plus({ days: 14 }));
const timeclockQuery = ` const timeclockQuery = `
SELECT SELECT
@@ -296,7 +307,7 @@ router.get('/', async (req, res) => {
e.lastname e.lastname
FROM timeclock tc FROM timeclock tc
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid 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.hidden = 0
AND e.disabled = 0 AND e.disabled = 0
ORDER BY tc.EmployeeID, tc.TimeStamp ORDER BY tc.EmployeeID, tc.TimeStamp
@@ -322,8 +333,8 @@ router.get('/', async (req, res) => {
// Get previous pay period data for comparison // Get previous pay period data for comparison
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1); const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [ const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
prevPayPeriod.start.toJSDate(), toMySqlBound(prevPayPeriod.start),
prevPayPeriod.end.toJSDate(), toMySqlBound(prevPayPeriod.start.plus({ days: 14 })),
]); ]);
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod); const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
@@ -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,
};
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { CampaignsService } from '../../services/klaviyo/campaigns.service.js'; 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) { export function createCampaignsRouter(apiKey, apiRevision, redis) {
const router = express.Router(); const router = express.Router();
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { EventsService } from '../../services/klaviyo/events.service.js'; 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 { RedisService } from '../../services/klaviyo/redis.service.js';
import { requirePermission } from '../../../shared/auth/middleware.js'; import { requirePermission } from '../../../shared/auth/middleware.js';
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { ReportingService } from '../../services/klaviyo/reporting.service.js'; 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) { export function createReportingRouter(apiKey, apiRevision, redis) {
const router = express.Router(); const router = express.Router();
@@ -1,5 +1,5 @@
import fetch from 'node-fetch'; 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 { RedisService } from './redis.service.js';
export class CampaignsService { export class CampaignsService {
@@ -1,5 +1,5 @@
import fetch from 'node-fetch'; 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 { RedisService } from './redis.service.js';
import _ from 'lodash'; import _ from 'lodash';
@@ -14,7 +14,7 @@
// Reads short-circuit to null when the client isn't ready; writes are no-ops. // 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. // 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 { export class RedisService {
constructor(redis) { constructor(redis) {
@@ -1,5 +1,5 @@
import fetch from 'node-fetch'; 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 { RedisService } from './redis.service.js';
const METRIC_IDS = { const METRIC_IDS = {
@@ -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));
}
}
+67
View File
@@ -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:001: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).
+10
View File
@@ -21,6 +21,7 @@
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"luxon": "^3.7.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"openai": "^6.0.0", "openai": "^6.0.0",
@@ -3658,6 +3659,15 @@
"url": "https://github.com/sponsors/wellwelwel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -32,6 +32,7 @@
"express-rate-limit": "^7.4.0", "express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"luxon": "^3.7.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"openai": "^6.0.0", "openai": "^6.0.0",
@@ -140,6 +140,12 @@ console.log('DB Connection Info:', {
password: (dbConfig.password || dbConfig.connectionString) ? '****' : 'MISSING' // Only show if credentials exist 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 pool = new Pool(dbConfig);
const getConnection = () => { const getConnection = () => {
@@ -18,6 +18,18 @@ import json
import time import time
import logging import logging
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from zoneinfo import ZoneInfo
BUSINESS_TZ = ZoneInfo('America/Chicago')
def business_today() -> 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 numpy as np
import pandas as pd import pandas as pd
@@ -740,7 +752,7 @@ def batch_load_product_data(conn, products):
GROUP BY pid GROUP BY pid
""" """
adf = execute_query(conn, arrival_sql, [preorder_pids]) adf = execute_query(conn, arrival_sql, [preorder_pids])
today = date.today() today = business_today()
for _, row in adf.iterrows(): for _, row in adf.iterrows():
pid = int(row['pid']) pid = int(row['pid'])
fa = row['future_arrival'] fa = row['future_arrival']
@@ -948,7 +960,7 @@ def forecast_mature(product, history_df):
hist['snapshot_date'] = pd.to_datetime(hist['snapshot_date']) hist['snapshot_date'] = pd.to_datetime(hist['snapshot_date'])
hist = hist.set_index('snapshot_date')['units_sold'] hist = hist.set_index('snapshot_date')['units_sold']
full_index = pd.date_range( 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') periods=EXP_SMOOTHING_WINDOW, freq='D')
series = hist.reindex(full_index, fill_value=0.0).values.astype(float) 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...") log.info("Batch loading product data...")
batch_data = batch_load_product_data(conn, products) 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)] forecast_dates = [today + timedelta(days=i) for i in range(FORECAST_HORIZON_DAYS)]
# Pre-compute DOW and seasonal multipliers for each forecast date. # 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 load product data for per-product scaling
batch_data = batch_load_product_data(conn, active) batch_data = batch_load_product_data(conn, active)
today = date.today() today = business_today()
backfill_start = today - timedelta(days=backfill_days) backfill_start = today - timedelta(days=backfill_days)
# Create a synthetic run entry # Create a synthetic run entry
@@ -14,6 +14,10 @@
* /var/www/inventory/.env (or current process env). * /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 { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
+4
View File
@@ -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 path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
+17 -1
View File
@@ -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 const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
// SSH configuration // 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 = { const sshConfig = {
ssh: { ssh: {
host: process.env.PROD_SSH_HOST, host: process.env.PROD_SSH_HOST,
@@ -40,7 +51,12 @@ const sshConfig = {
password: process.env.PROD_DB_PASSWORD, password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME, database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306, 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: { localDbConfig: {
// PostgreSQL config for local // PostgreSQL config for local
+13 -3
View File
@@ -58,7 +58,12 @@ async function setupConnections(sshConfig) {
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec" "SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
); );
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST) 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; const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed) // CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed) // CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
@@ -79,8 +84,13 @@ async function setupConnections(sshConfig) {
} }
prodConnection.adjustDateForMySQL = adjustDateForMySQL; prodConnection.adjustDateForMySQL = adjustDateForMySQL;
// Setup PostgreSQL connection pool for local // Setup PostgreSQL connection pool for local.
const localPool = new Pool(sshConfig.localDbConfig); // 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 // Test the PostgreSQL connection
try { try {
@@ -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. -- Description: Calculates and updates aggregated metrics per brand.
-- Dependencies: product_metrics, products, calculate_status table. -- Dependencies: product_metrics, products, calculate_status table.
-- Frequency: Daily (after product_metrics update). -- Frequency: Daily (after product_metrics update).
@@ -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. -- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table. -- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
-- Frequency: Daily (after product_metrics update). -- Frequency: Daily (after product_metrics update).
@@ -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. -- Description: Calculates and updates aggregated metrics per vendor.
-- Dependencies: product_metrics, products, purchase_orders, calculate_status table. -- Dependencies: product_metrics, products, purchase_orders, calculate_status table.
-- Frequency: Daily (after product_metrics update). -- Frequency: Daily (after product_metrics update).
@@ -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. -- Description: Calculates and updates daily aggregated product data.
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot -- Self-healing: detects gaps (missing snapshots), stale data (snapshot
-- aggregates that don't match source tables after backfills), and always -- aggregates that don't match source tables after backfills), and always
@@ -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. -- 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. -- 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. -- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
@@ -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 -- Description: Calculates metrics that don't need hourly updates, like ABC class
-- and average lead time. -- and average lead time.
-- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status. -- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status.
@@ -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 -- Description: Calculates and updates the main product_metrics table based on current data
-- and aggregated daily snapshots. Uses UPSERT for idempotency. -- and aggregated daily snapshots. Uses UPSERT for idempotency.
-- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status. -- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status.
@@ -10,6 +10,9 @@ const dbConfig = {
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT || 5432,
ssl: process.env.DB_SSL === 'true', 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 // Add performance optimizations
max: 10, // connection pool max size max: 10, // connection pool max size
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
@@ -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: todayyesterday, thisWeeklastWeek, ) 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)
);
}
}
+3
View File
@@ -15,5 +15,8 @@ export function createPool(envPrefix = 'DB', overrides = {}) {
max: overrides.max ?? 20, max: overrides.max ?? 20,
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000, idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_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',
}); });
} }
+4 -2
View File
@@ -14,7 +14,8 @@
"./logging": "./logging/index.js", "./logging": "./logging/index.js",
"./errors/handler": "./errors/handler.js", "./errors/handler": "./errors/handler.js",
"./cors/policy": "./cors/policy.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": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -23,6 +24,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pg": "^8.11.3", "pg": "^8.11.3",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-http": "^10.3.0" "pino-http": "^10.3.0",
"luxon": "^3.7.2"
} }
} }
+11 -12
View File
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
// Forecasting: summarize sales for products received in a period by brand // 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' }); return res.status(400).json({ error: 'Missing required parameter: brand' });
} }
// Default to last 30 days if no dates provided // Default to last 30 business days if no dates provided. Params are
const endDate = endDateStr ? new Date(endDateStr) : new Date(); // date-only business dates ('YYYY-MM-DD'); instants are converted.
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000); const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
const startISO = startDateStr
// Normalize to date boundaries for consistency ? businessDateOf(startDateStr)
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString(); : new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
const sql = ` const sql = `
WITH params AS ( WITH params AS (
@@ -177,11 +177,10 @@ router.get('/forecast-v2', async (req, res) => {
return res.status(400).json({ error: 'Missing required parameter: brand' }); return res.status(400).json({ error: 'Missing required parameter: brand' });
} }
const endDate = endDateStr ? new Date(endDateStr) : new Date(); const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000); const startISO = startDateStr
? businessDateOf(startDateStr)
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString(); : new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
const sql = ` const sql = `
WITH params AS ( WITH params AS (
+29 -44
View File
@@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import db from '../utils/db.js'; import db from '../utils/db.js';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
// Helper function to execute queries using the connection pool // 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). // Reads from product_forecasts table (lifecycle-aware forecasting pipeline).
// Falls back to velocity-based projection if forecast table is empty. // Falls back to velocity-based projection if forecast table is empty.
router.get('/forecast/metrics', async (req, res) => { router.get('/forecast/metrics', async (req, res) => {
const today = new Date(); // Business dates (Chicago calendar — see docs/TIME.md). Inputs may be
const thirtyDaysOut = new Date(today); // date-only strings or full instants.
thirtyDaysOut.setDate(today.getDate() + 30); const startISO = req.query.startDate ? businessDateOf(req.query.startDate) : businessTodayStr();
const endISO = req.query.endDate
const startDate = req.query.startDate ? new Date(req.query.startDate) : today; ? businessDateOf(req.query.endDate)
const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut; : businessNow().plus({ days: 30 }).toISODate();
const startISO = startDate.toISOString().split('T')[0]; const days = Math.max(1, Math.round((Date.parse(endISO) - Date.parse(startISO)) / (1000 * 60 * 60 * 24)));
const endISO = endDate.toISOString().split('T')[0];
const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)));
try { try {
// Check if product_forecasts has data // Check if product_forecasts has data
@@ -331,9 +330,7 @@ router.get('/forecast/metrics', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery( const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts` `SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
); );
const forecastHorizonISO = horizonRow.max_date instanceof Date const forecastHorizonISO = formatDateCol(horizonRow.max_date);
? horizonRow.max_date.toISOString().split('T')[0]
: horizonRow.max_date;
const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00'); const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00');
const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO; const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO;
const needsExtrapolation = endISO > forecastHorizonISO; const needsExtrapolation = endISO > forecastHorizonISO;
@@ -376,7 +373,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]); `, [startISO, clampedEndISO]);
const dailyForecasts = dailyRows.map(d => ({ 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, units: parseFloat(d.units) || 0,
revenue: parseFloat(d.revenue) || 0, revenue: parseFloat(d.revenue) || 0,
confidence: confidenceLevel, confidence: confidenceLevel,
@@ -475,7 +472,7 @@ router.get('/forecast/metrics', async (req, res) => {
const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits; const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits;
dailyForecasts.push({ dailyForecasts.push({
date: d.toISOString().split('T')[0], date: formatDateCol(d),
units: parseFloat(estUnits.toFixed(1)), units: parseFloat(estUnits.toFixed(1)),
revenue: parseFloat(estRevenue.toFixed(2)), revenue: parseFloat(estRevenue.toFixed(2)),
confidence: 0, // lower confidence for extrapolated data confidence: 0, // lower confidence for extrapolated data
@@ -539,7 +536,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]); `, [startISO, clampedEndISO]);
const dailyForecastsByPhase = dailyPhaseRows.map(d => ({ 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, preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0, launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 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 dailyRevenue = parseFloat(totals.daily_revenue) || 0;
const dailyForecasts = []; const dailyForecasts = [];
const fallbackStart = new Date(startISO + 'T00:00:00'); // local midnight — round-trips via formatDateCol
for (let i = 0; i < days; i++) { for (let i = 0; i < days; i++) {
const d = new Date(startDate); const d = new Date(fallbackStart);
d.setDate(startDate.getDate() + i); d.setDate(fallbackStart.getDate() + i);
dailyForecasts.push({ dailyForecasts.push({
date: d.toISOString().split('T')[0], date: formatDateCol(d),
units: parseFloat(dailyUnits.toFixed(1)), units: parseFloat(dailyUnits.toFixed(1)),
revenue: parseFloat(dailyRevenue.toFixed(2)), revenue: parseFloat(dailyRevenue.toFixed(2)),
confidence: 0, confidence: 0,
@@ -776,7 +774,7 @@ router.get('/forecast/accuracy', async (req, res) => {
`); `);
const accuracyTrend = trendRows.map(r => ({ 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, mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null,
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null, wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : 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 => ({ 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, 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, 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, fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
@@ -808,12 +806,8 @@ router.get('/forecast/accuracy', async (req, res) => {
computedAt, computedAt,
daysOfHistory: parseInt(historyInfo.days_of_history) || 0, daysOfHistory: parseInt(historyInfo.days_of_history) || 0,
historyRange: { historyRange: {
from: historyInfo.earliest_date instanceof Date from: formatDateCol(historyInfo.earliest_date),
? historyInfo.earliest_date.toISOString().split('T')[0] to: formatDateCol(historyInfo.latest_date),
: historyInfo.earliest_date,
to: historyInfo.latest_date instanceof Date
? historyInfo.latest_date.toISOString().split('T')[0]
: historyInfo.latest_date,
}, },
overall: shapeOverall(overall), overall: shapeOverall(overall),
overallInclDormant: shapeOverall(overallInclDormant), overallInclDormant: shapeOverall(overallInclDormant),
@@ -1038,10 +1032,9 @@ router.get('/best-sellers', async (req, res) => {
// GET /dashboard/year-revenue-estimate // GET /dashboard/year-revenue-estimate
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate // Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
router.get('/year-revenue-estimate', async (req, res) => { router.get('/year-revenue-estimate', async (req, res) => {
const now = new Date(); const todayISO = businessTodayStr(); // business 'today', not UTC
const yearStart = `${now.getFullYear()}-01-01`; const yearStart = `${todayISO.slice(0, 4)}-01-01`;
const todayISO = now.toISOString().split('T')[0]; const yearEndISO = `${todayISO.slice(0, 4)}-12-31`;
const yearEndISO = `${now.getFullYear()}-12-31`;
try { try {
// YTD actual revenue from orders // YTD actual revenue from orders
@@ -1055,11 +1048,7 @@ router.get('/year-revenue-estimate', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery( const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts` `SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
); );
const forecastHorizonISO = horizonRow?.max_date const forecastHorizonISO = horizonRow?.max_date ? formatDateCol(horizonRow.max_date) : todayISO;
? (horizonRow.max_date instanceof Date
? horizonRow.max_date.toISOString().split('T')[0]
: horizonRow.max_date)
: todayISO;
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO; const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
@@ -1111,13 +1100,9 @@ router.get('/year-revenue-estimate', async (req, res) => {
// GET /dashboard/sales/metrics // GET /dashboard/sales/metrics
// Returns sales metrics for specified period // Returns sales metrics for specified period
router.get('/sales/metrics', async (req, res) => { router.get('/sales/metrics', async (req, res) => {
// Default to last 30 days if no date range provided // Default: trailing 30 business days ending now (Chicago day start).
const today = new Date(); const startDate = req.query.startDate || businessNow().minus({ days: 30 }).startOf('day').toISO();
const thirtyDaysAgo = new Date(today); const endDate = req.query.endDate || new Date().toISOString();
thirtyDaysAgo.setDate(today.getDate() - 30);
const startDate = req.query.startDate || thirtyDaysAgo.toISOString();
const endDate = req.query.endDate || today.toISOString();
try { try {
// Get daily orders and totals for the specified period // 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, totalCogs: parseFloat(metrics?.total_cogs) || 0,
totalRevenue: parseFloat(metrics?.total_revenue) || 0, totalRevenue: parseFloat(metrics?.total_revenue) || 0,
dailySales: dailyRows.map(day => ({ dailySales: dailyRows.map(day => ({
date: day.sale_date, date: formatDateCol(day.sale_date),
units: parseInt(day.total_units) || 0, units: parseInt(day.total_units) || 0,
revenue: parseFloat(day.total_revenue) || 0, revenue: parseFloat(day.total_revenue) || 0,
cogs: parseFloat(day.total_cogs) || 0 cogs: parseFloat(day.total_cogs) || 0
})), })),
dailySalesByPhase: dailyPhaseRows.map(d => ({ dailySalesByPhase: dailyPhaseRows.map(d => ({
date: d.sale_date, date: formatDateCol(d.sale_date),
preorder: parseFloat(d.preorder) || 0, preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0, launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 0, decay: parseFloat(d.decay) || 0,
+4 -1
View File
@@ -676,7 +676,10 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD, password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME, database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306, 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) => { return new Promise((resolve, reject) => {
+8 -6
View File
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
// Get all orders with pagination, filtering, and sorting // Get all orders with pagination, filtering, and sorting
@@ -10,8 +11,9 @@ router.get('/', async (req, res) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const search = req.query.search || ''; const search = req.query.search || '';
const status = req.query.status || 'all'; const status = req.query.status || 'all';
const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null; // Date filters are BUSINESS dates ('YYYY-MM-DD', Chicago calendar).
const toDate = req.query.toDate ? new Date(req.query.toDate) : null; 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 minAmount = parseFloat(req.query.minAmount) || 0;
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null; const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
const sortColumn = req.query.sortColumn || 'date'; const sortColumn = req.query.sortColumn || 'date';
@@ -35,14 +37,14 @@ router.get('/', async (req, res) => {
} }
if (fromDate) { if (fromDate) {
conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`); conditions.push(`DATE(o1.date) >= $${paramCounter}::date`);
params.push(fromDate.toISOString()); params.push(fromDate);
paramCounter++; paramCounter++;
} }
if (toDate) { if (toDate) {
conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`); conditions.push(`DATE(o1.date) <= $${paramCounter}::date`);
params.push(toDate.toISOString()); params.push(toDate);
paramCounter++; paramCounter++;
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { formatDateCol } from '../../shared/business-time/index.js';
import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js'; import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js';
const router = express.Router(); const router = express.Router();
@@ -1003,7 +1004,7 @@ router.get('/:id/forecast', async (req, res) => {
phase, phase,
method, method,
forecast: rows.map(r => ({ 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, units: parseFloat(r.units) || 0,
revenue: parseFloat(r.revenue) || 0, revenue: parseFloat(r.revenue) || 0,
confidenceLower: parseFloat(r.confidence_lower) || 0, confidenceLower: parseFloat(r.confidence_lower) || 0,
@@ -1,4 +1,5 @@
import express from 'express'; import express from 'express';
import { businessNow } from '../../shared/business-time/index.js';
const router = express.Router(); const router = express.Router();
// Status code constants // Status code constants
@@ -716,7 +717,7 @@ router.get('/category-analysis', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago // 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(` const { rows: analysis } = await pool.query(`
WITH receiving_costs AS ( WITH receiving_costs AS (
@@ -769,7 +770,7 @@ router.get('/vendor-analysis', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago // 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(` const { rows: metrics } = await pool.query(`
WITH receiving_data AS ( WITH receiving_data AS (
+7 -1
View File
@@ -5,7 +5,13 @@ const { Pool } = pg;
let pool; let pool;
export function initPool(config) { 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; return pool;
} }
+4 -1
View File
@@ -97,7 +97,10 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD, password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME, database: process.env.PROD_DB_NAME,
port: Number(process.env.PROD_DB_PORT) || 3306, 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) => { return new Promise((resolve, reject) => {
@@ -17,6 +17,7 @@ import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency'; import { formatCurrency } from '@/utils/formatCurrency';
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react'; import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface FlowPoint { interface FlowPoint {
date: string; date: string;
@@ -71,21 +72,7 @@ export function InventoryFlow() {
Daily cost of goods received vs cost of goods sold Daily cost of goods received vs cost of goods sold
</p> </p>
</div> </div>
<div className="flex gap-1"> <HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90]} />
{([30, 90] as Period[]).map((p) => (
<button
key={p}
onClick={() => 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`}
</button>
))}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -14,6 +14,7 @@ import {
} from 'recharts'; } from 'recharts';
import config from '../../config'; import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface TrendPoint { interface TrendPoint {
date: string; date: string;
@@ -52,21 +53,7 @@ export function InventoryTrends() {
Units sold per day with stockout product count overlay Units sold per day with stockout product count overlay
</p> </p>
</div> </div>
<div className="flex gap-1"> <HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90, 365]} />
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => 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`}
</button>
))}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -15,6 +15,7 @@ import {
import config from '../../config'; import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency'; import { formatCurrency } from '@/utils/formatCurrency';
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
interface ValuePoint { interface ValuePoint {
date: string; date: string;
@@ -69,21 +70,7 @@ export function InventoryValueTrend() {
)} )}
</p> </p>
</div> </div>
<div className="flex gap-1"> <HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90, 365]} />
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => 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`}
</button>
))}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+17 -13
View File
@@ -30,6 +30,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn } from "@/lib/utils"; 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 DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const [datetime, setDatetime] = useState(new Date()); const [datetime, setDatetime] = useState(new Date());
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date())); const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
@@ -93,11 +97,10 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
}, [prevTime]); }, [prevTime]);
function getTimeComponents(date) { function getTimeComponents(date) {
let hours = date.getHours(); const hour24 = officeHour(date);
const minutes = date.getMinutes(); const minutes = Number(date.toLocaleString('en-US', { minute: 'numeric', timeZone: OFFICE_TZ }));
const ampm = hours >= 12 ? 'PM' : 'AM'; const ampm = hour24 >= 12 ? 'PM' : 'AM';
hours = hours % 12; const hours = hour24 % 12 || 12;
hours = hours ? hours : 12;
return { return {
hours: hours.toString(), hours: hours.toString(),
minutes: minutes.toString().padStart(2, '0'), minutes: minutes.toString().padStart(2, '0'),
@@ -107,9 +110,9 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const formatDate = (date) => { const formatDate = (date) => {
return { return {
weekday: date.toLocaleDateString('en-US', { weekday: 'long' }), weekday: date.toLocaleDateString('en-US', { weekday: 'long', timeZone: OFFICE_TZ }),
month: date.toLocaleDateString('en-US', { month: 'long' }), month: date.toLocaleDateString('en-US', { month: 'long', timeZone: OFFICE_TZ }),
day: date.getDate() day: Number(date.toLocaleDateString('en-US', { day: 'numeric', timeZone: OFFICE_TZ }))
}; };
}; };
@@ -117,7 +120,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />; if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
const code = parseInt(weatherCode, 10); const code = parseInt(weatherCode, 10);
const iconProps = small ? "w-8 h-8" : "w-12 h-12"; 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) { switch (true) {
case code >= 200 && code < 300: case code >= 200 && code < 300:
@@ -137,7 +140,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
case code === 781: case code === 781:
return <Tornado className={cn(iconProps, "text-gray-300")} />; return <Tornado className={cn(iconProps, "text-gray-300")} />;
case code === 800: case code === 800:
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? ( return officeHour(currentTime) >= 6 && officeHour(currentTime) < 18 ? (
<Sun className={cn(iconProps, "text-yellow-300")} /> <Sun className={cn(iconProps, "text-yellow-300")} />
) : ( ) : (
<Moon className={cn(iconProps, "text-gray-300")} /> <Moon className={cn(iconProps, "text-gray-300")} />
@@ -230,7 +233,8 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: '2-digit', minute: '2-digit',
hour12: true hour12: true,
timeZone: OFFICE_TZ
}); });
}; };
@@ -303,7 +307,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
{forecast.map((day, index) => { {forecast.map((day, index) => {
const forecastTime = new Date(day.dt * 1000); const forecastTime = new Date(day.dt * 1000);
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6; const isNight = officeHour(forecastTime) >= 18 || officeHour(forecastTime) < 6;
return ( return (
<Card <Card
key={index} key={index}
@@ -392,7 +396,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
<Card className={cn( <Card className={cn(
getWeatherBackground( getWeatherBackground(
weather.weather[0]?.id, weather.weather[0]?.id,
datetime.getHours() >= 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" "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"
)}> )}>
@@ -43,6 +43,7 @@ import PeriodSelectionPopover, {
} from "@/components/dashboard/PeriodSelectionPopover"; } from "@/components/dashboard/PeriodSelectionPopover";
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod"; import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { toDateOnly } from "@/utils/businessTime";
import { import {
DashboardSectionHeader, DashboardSectionHeader,
DashboardStatCard, DashboardStatCard,
@@ -759,8 +760,11 @@ const FinancialOverview = () => {
return; return;
} }
params.timeRange = "custom"; params.timeRange = "custom";
params.startDate = requestRange.start.toISOString(); // Date-only strings = business dates; the server resolves them to
params.endDate = requestRange.end.toISOString(); // 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; const response = (await acotService.getFinancials(params)) as FinancialResponse;
@@ -8,15 +8,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, BookOpen } from "lucide-react"; import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
@@ -358,18 +351,7 @@ const KlaviyoCampaigns = ({ className }) => {
</div> </div>
} }
timeSelector={ timeSelector={
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}> <BusinessRangeSelect value={selectedTimeRange} onValueChange={setSelectedTimeRange} />
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
} }
/> />
<CardContent className="pl-4 mb-4"> <CardContent className="pl-4 mb-4">
@@ -47,6 +47,7 @@ import {
METRIC_COLORS, METRIC_COLORS,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toDateOnly } from "@/utils/businessTime";
type ComparisonValue = { type ComparisonValue = {
absolute: number | null; absolute: number | null;
@@ -335,8 +336,11 @@ const OperationsMetrics = () => {
return; return;
} }
params.timeRange = "custom"; params.timeRange = "custom";
params.startDate = requestRange.start.toISOString(); // Date-only strings = business dates; the server resolves them to
params.endDate = requestRange.end.toISOString(); // 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 // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
@@ -12,16 +12,9 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
@@ -235,21 +228,10 @@ const ProductGrid = ({
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
</Button> </Button>
)} )}
<Select <BusinessRangeSelect
value={selectedTimeRange} value={selectedTimeRange}
onValueChange={handleTimeRangeChange} onValueChange={handleTimeRangeChange}
> />
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
{isSearchVisible && !error && ( {isSearchVisible && !error && (
@@ -1,13 +1,7 @@
import React, { useState, useEffect, useCallback, memo } from "react"; import React, { useState, useEffect, useCallback, memo } from "react";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { import {
@@ -21,7 +15,6 @@ import {
Legend, Legend,
ReferenceLine, ReferenceLine,
} from "recharts"; } from "recharts";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { import {
Table, Table,
TableHeader, TableHeader,
@@ -463,21 +456,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
// Time selector for DashboardSectionHeader // Time selector for DashboardSectionHeader
const timeSelector = ( const timeSelector = (
<Select <BusinessRangeSelect
value={selectedTimeRange} value={selectedTimeRange}
onValueChange={handleTimeRangeChange} onValueChange={handleTimeRangeChange}
> />
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
); );
// Actions (Details dialog) for DashboardSectionHeader // Actions (Details dialog) for DashboardSectionHeader
@@ -7,13 +7,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -49,7 +43,6 @@ import {
MapPin, MapPin,
} from "lucide-react"; } from "lucide-react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardStatCard, DashboardStatCard,
@@ -1475,18 +1468,7 @@ const StatCards = ({
Last updated: {lastUpdate.toFormat("hh:mm a")} Last updated: {lastUpdate.toFormat("hh:mm a")}
</span> </span>
)} )}
<Select value={timeRange} onValueChange={setTimeRange}> <BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -1547,18 +1529,7 @@ const StatCards = ({
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Select value={timeRange} onValueChange={setTimeRange}> <BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -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 <Select>-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 (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger id={id} className={cn("w-[130px] h-9", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default BusinessRangeSelect;
@@ -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 <Tabs>, 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<number, string> = {
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 (
<Tabs
value={String(value)}
onValueChange={(v) => onChange(Number(v))}
className={className}
>
<TabsList>
{options.map((days) => (
<TabsTrigger key={days} value={String(days)}>
{HORIZON_LABELS[days] ?? `${days}D`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}
export default HorizonTabs;
@@ -55,16 +55,16 @@ export function DateRangePickerQuick({ value, onChange, className }: DateRangePi
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}> <Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}>
Last Month Trailing Month
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -3), 1), to: new Date() })}> <Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -3), 1), to: new Date() })}>
Last 3 Months Trailing 3 Months
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}> <Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}>
Last 6 Months Trailing 6 Months
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}> <Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}>
Last Year Trailing 12 Months
</Button> </Button>
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency" import { formatCurrency } from "@/utils/formatCurrency"
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react" import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" 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 { addDays, format } from "date-fns"
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases" import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"
@@ -74,13 +74,7 @@ export function SalesMetrics() {
<> <>
<CardHeader className="flex flex-row items-center justify-between pr-5"> <CardHeader className="flex flex-row items-center justify-between pr-5">
<CardTitle className="text-xl font-medium">Sales</CardTitle> <CardTitle className="text-xl font-medium">Sales</CardTitle>
<Tabs value={String(period)} onValueChange={(v) => setPeriod(Number(v) as Period)}> <HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
<TabsList>
<TabsTrigger value="7">7D</TabsTrigger>
<TabsTrigger value="30">30D</TabsTrigger>
<TabsTrigger value="90">90D</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader> </CardHeader>
<CardContent className="py-0 -mb-2"> <CardContent className="py-0 -mb-2">
{isError ? ( {isError ? (
@@ -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 (
<div className={cn("grid gap-1", className)}>
<Popover>
<PopoverTrigger asChild className="p-3">
<Button
id="date"
variant={"outline"}
className={cn(
"h-8 w-[230px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL d, y")} -{" "}
{format(value.to, "LLL d, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<div className="flex gap-2 pb-4">
{presets.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => onChange(preset.range)}
>
{preset.label}
</Button>
))}
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={(range) => {
if (range) onChange(range);
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
+7 -2
View File
@@ -49,6 +49,7 @@ import { acotService } from "@/services/dashboard/acotService";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { businessTodayStr } from "@/utils/businessTime";
type BlackFridayRange = { type BlackFridayRange = {
start: DateTime; start: DateTime;
@@ -513,8 +514,12 @@ function LoadingBlock() {
} }
export function BlackFridayDashboard() { export function BlackFridayDashboard() {
const currentYear = new Date().getUTCFullYear(); // Anchor on the BUSINESS date (Chicago calendar), not UTC.
const currentMonth = new Date().getUTCMonth() + 1; // getUTCMonth() returns 0-11, so add 1 const [businessYear, businessMonth] = businessTodayStr()
.split("-")
.map(Number);
const currentYear = businessYear;
const currentMonth = businessMonth;
// Show previous year until November of current year // Show previous year until November of current year
const displayYear = currentMonth < 11 ? currentYear - 1 : currentYear; const displayYear = currentMonth < 11 ? currentYear - 1 : currentYear;
const availableYears = useMemo( const availableYears = useMemo(
+16 -9
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DateRange } from "react-day-picker"; 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 { useMutation, useQuery } from "@tanstack/react-query";
import { motion } from "motion/react"; import { motion } from "motion/react";
@@ -20,6 +20,7 @@ import {
CogsCalculationMode, CogsCalculationMode,
} from "@/types/discount-simulator"; } from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { toDateOnly } from "@/utils/businessTime";
const DEFAULT_POINT_VALUE = 0.005; const DEFAULT_POINT_VALUE = 0.005;
const DEFAULT_REDEMPTION_RATE = 0.9; const DEFAULT_REDEMPTION_RATE = 0.9;
@@ -78,8 +79,9 @@ export function DiscountSimulator() {
const promoDateBounds = useMemo(() => { const promoDateBounds = useMemo(() => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
return { return {
startDate: startOfDay(from).toISOString(), // Date-only strings = business dates; the server resolves bounds.
endDate: endOfDay(to).toISOString(), startDate: toDateOnly(from),
endDate: toDateOnly(to),
}; };
}, [dateRange]); }, [dateRange]);
@@ -138,8 +140,8 @@ export function DiscountSimulator() {
return { return {
dateRange: { dateRange: {
start: startOfDay(from).toISOString(), start: toDateOnly(from),
end: endOfDay(to).toISOString(), end: toDateOnly(to),
}, },
filters: { filters: {
shipCountry: 'US', shipCountry: 'US',
@@ -271,8 +273,13 @@ export function DiscountSimulator() {
if (parsed.dateRange) { if (parsed.dateRange) {
const defaultRange = getDefaultDateRange(); const defaultRange = getDefaultDateRange();
const fromValue = parsed.dateRange.start ? new Date(parsed.dateRange.start) : undefined; // Saved values may be date-only ('YYYY-MM-DD') or legacy ISO instants.
const toValue = parsed.dateRange.end ? new Date(parsed.dateRange.end) : undefined; // 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({ setDateRange({
from: fromValue ?? defaultRange.from, from: fromValue ?? defaultRange.from,
to: toValue ?? defaultRange.to, to: toValue ?? defaultRange.to,
@@ -348,8 +355,8 @@ export function DiscountSimulator() {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
return JSON.stringify({ return JSON.stringify({
dateRange: { dateRange: {
start: startOfDay(from).toISOString(), start: toDateOnly(from),
end: endOfDay(to).toISOString(), end: toDateOnly(to),
}, },
selectedPromoId: selectedPromoId ?? null, selectedPromoId: selectedPromoId ?? null,
productPromo, productPromo,
+4 -2
View File
@@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input";
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react"; import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toDateOnly } from "@/utils/businessTime";
type GroupMode = "line" | "category" | "designer"; type GroupMode = "line" | "category" | "designer";
@@ -126,8 +127,9 @@ export default function Forecasting() {
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
brand: selectedBrand, brand: selectedBrand,
startDate: dateRange.from?.toISOString() || "", // Date-only strings = business dates (see docs/TIME.md)
endDate: dateRange.to?.toISOString() || "", startDate: dateRange.from ? toDateOnly(dateRange.from) : "",
endDate: dateRange.to ? toDateOnly(dateRange.to) : "",
}); });
const response = await apiFetch(`/api/analytics/forecast-v2?${params}`); const response = await apiFetch(`/api/analytics/forecast-v2?${params}`);
if (!response.ok) throw new Error("Failed to fetch forecast data"); if (!response.ok) throw new Error("Failed to fetch forecast data");
+33
View File
@@ -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}`;
}
File diff suppressed because one or more lines are too long