Time unification
This commit is contained in:
@@ -50,6 +50,10 @@ dashboard-server/meta-server/._package-lock.json
|
||||
dashboard-server/meta-server/._services
|
||||
*.tsbuildinfo
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
uploads/*
|
||||
uploads/**/*
|
||||
**/uploads/*
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Time Unification Plan
|
||||
|
||||
Audit date: 2026-06-12. Companion to `IMPORT_METRICS_FIX_PLAN.md` (import/metrics day-bucketing fixes, implemented 2026-06-11).
|
||||
|
||||
> **IMPLEMENTATION STATUS (2026-06-12):** Phases 1–6 code implemented. Shared
|
||||
> module at `inventory-server/shared/business-time/` (+ frontend mirror
|
||||
> `inventory/src/utils/businessTime.ts`); both duplicate utils deleted;
|
||||
> acot-server hour-late bug fixed; `dateStrings: true` on all mysql2 configs
|
||||
> except the import pipeline (dynamic Chicago offset instead);
|
||||
> `forecast_engine.py` anchors on `business_today()`; pg pools pin
|
||||
> `TimeZone=America/Chicago`; `/var/www/ecosystem.config.cjs` has
|
||||
> `TZ: 'America/Chicago'` (backup at `~/ecosystem.config.cjs.bak.2026-06-12`);
|
||||
> supervised import+metrics cycle ran clean. Docs at
|
||||
> `inventory-server/docs/TIME.md`.
|
||||
>
|
||||
> **Still pending (Matt):** ① `sudo -u postgres psql -c "ALTER DATABASE
|
||||
> inventory_db SET timezone = 'America/Chicago';"` ② `pm2 reload
|
||||
> ecosystem.config.cjs --update-env` (mind the JWT_SECRET shell-var gotcha)
|
||||
> ③ flush dashboard Redis ④ frontend `npm run build` (deploys) ⑤ Phase 2
|
||||
> before/after verification + Klaviyo UTC spot-check. Deferred (non-correctness):
|
||||
> GA4/Meta footnote labels. (UPDATE 2026-06-13: the two-selector-kit UI
|
||||
> consolidation — `BusinessRangeSelect` + `HorizonTabs` — is now implemented; see Phase 5.)
|
||||
|
||||
## The Convention (single source of truth)
|
||||
|
||||
**A business day runs 1:00:00am Eastern → 12:59:59am Eastern the next day.**
|
||||
|
||||
Because 1am Eastern == midnight Central year-round (both observe US DST), this is identical to:
|
||||
|
||||
> **Business date = the America/Chicago calendar date.**
|
||||
|
||||
Practical consequences — these are the ONLY approved idioms:
|
||||
|
||||
|
||||
| Context | Correct idiom |
|
||||
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PG `timestamptz` → business date | `(ts AT TIME ZONE 'America/Chicago')::date` — or plain `ts::date` / `CURRENT_DATE` once Phase 1 sets the session TZ |
|
||||
| MySQL DATETIME literal (stores Central wall-clock) → business date | `DATE(col)` — **no hour shift** |
|
||||
| MySQL WHERE bounds for a business day | Central wall-clock strings: `col >= 'YYYY-MM-DD 00:00:00' AND col < 'YYYY-MM-DD+1 00:00:00'` |
|
||||
| Luxon | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset |
|
||||
| Intervals | **Half-open** `[start, nextStart)`. No `-1 minute`, no `-1 ms` endpoints |
|
||||
| "Today" in JS | a `businessToday()` helper (Chicago date), never `new Date().toISOString().split('T')[0]` (UTC) and never bare `CURRENT_DATE` reasoning in Node |
|
||||
|
||||
|
||||
## Verified facts (2026-06-11/12)
|
||||
|
||||
- MySQL (192.168.1.5) session/global TZ = SYSTEM = America/Chicago. `NOW()` = UTC-5 (CDT) ✓ live-checked.
|
||||
- `_order.date_placed`, `_order.date_shipped` are DATETIME storing **Central wall-clock** ✓ (recent order: `date_placed 18:28` at 23:45 UTC).
|
||||
- `timeclock.TimeStamp` stores **Central wall-clock** ✓ (8am-ET punch-ins recorded as ~07:03–07:06; `NOW()` = 08:14 at ~9:14am ET).
|
||||
- `picking_ticket.createddate` — PHP-written, Central (same as all PHP DATETIME writes; spot-check during Phase 2 anyway).
|
||||
- PostgreSQL `inventory_db` session TZ = **Europe/Berlin** ✓ (`SHOW timezone`).
|
||||
- Node processes on netcup run with system TZ Europe/Berlin (no `TZ` in `.env`/ecosystem).
|
||||
- Browsers/office = America/New_York.
|
||||
- GA4 property reporting TZ = America/New_York (per Matt); API `NdaysAgo` resolves in property TZ.
|
||||
- Klaviyo account TZ = Eastern; API documented to return UTC — **verify in Phase 5** (one event spot-check).
|
||||
- PG columns: `orders.date`, `receivings.received_date`, `purchase_orders.date`, `products.created_at/date_online`, `product_metrics.last_calculated` = timestamptz; `stock_snapshots.snapshot_date`, `daily_product_snapshots.snapshot_date`, `purchase_orders.expected_date`, `product_forecasts.forecast_date` = DATE.
|
||||
|
||||
## Known defects this plan fixes
|
||||
|
||||
1. **acot-server dashboard windows are 1 hour late** (today = 2am–2am ET): `toDatabaseSqlString()` in `dashboard/acot-server/utils/timeUtils.js` passes Eastern wall-clock (via `keepLocalTime: true` + fixed `UTC-05:00`) to a Central-literal DB.
|
||||
2. **Fixed `UTC-05:00` offsets** in `timeUtils.js` (`DB_TIMEZONE`) and `events.js:639` — wrong every winter (CST = UTC-6).
|
||||
3. `**DATE_SUB(col, INTERVAL 1 HOUR)` daily bucketing** in operations/employee metrics — assumed Eastern storage; columns are Central, so buckets are also 1h late.
|
||||
4. **payroll-metrics punch parsing**: mysql2 `timezone: 'Z'` turns `07:58` Central into `07:58Z`, then luxon renders it as 03:58 ET — punches shifted −5h before pay-period math; overnight punches (midnight–5am CT) land on the wrong day.
|
||||
5. **PG routes use Berlin "today"**: every `CURRENT_DATE` / `NOW()` / `::date` / `DATE(col)` in `src/routes/`* and the window anchors in `scripts/metrics-new/*.sql` flip to the next business day at 6pm ET.
|
||||
6. **JS UTC "today"** (`new Date().toISOString().split('T')[0]`) in `src/routes/dashboard.js` and others flips at 8pm ET — a third clock.
|
||||
7. **DATE-column serialization off-by-one**: node-postgres returns `DATE` as a JS Date at *Berlin local midnight* (= 22:00/23:00 UTC the previous day); `d.toISOString().split('T')[0]` then emits the **previous day**. Affects `src/routes/dashboard.js` (~lines 335, 379, 478, 542, 779, 799, 812) and `src/routes/products.js:1006` wherever the value is a true DATE column.
|
||||
8. **Two duplicate luxon utils disagree**: `TimeManager.getDayEnd` = next 1am −1 *minute* (59.999s hole); `timeUtils.getDayEnd` = −1 ms. `TimeManager.groupEventsByInterval` groups by `startOf('day')` in the event string's own zone — neither ET nor the 1am rule.
|
||||
9. **Routes that ignore the convention**: `discounts.js` (zoneless `DateTime.fromISO` + `startOf('day')` = Berlin midnight), `customers.js` (MySQL `NOW()` windows), `events.js` hourly chart (Central hour labels shown to an Eastern office).
|
||||
10. **Three mysql2 timezone configs for the same DB**: import `'-05:00'` (+ dynamic outbound fix), acot-server `'Z'`, `src/utils/dbConnection.js` + `src/routes/import.js` `'Z'`.
|
||||
11. **Frontend**: custom ranges are browser-local midnight→23:59:59 (not business days); Forecasting's "Last Month" preset = *trailing* month while everywhere else it's *calendar* month; BlackFridayDashboard does UTC date math; 5 different range-option vocabularies; `date-range-picker-narrow.tsx` is dead code.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — PostgreSQL & Node clocks (highest leverage, smallest diff)
|
||||
|
||||
**1.1 Set PG database timezone to business time.**
|
||||
|
||||
```sql
|
||||
ALTER DATABASE inventory_db SET timezone = 'America/Chicago';
|
||||
```
|
||||
|
||||
Then reconnect all pools (PM2 reload, see gotcha note at bottom; the 15-min cron picks it up on next run automatically since it opens fresh connections).
|
||||
|
||||
Effect: every `CURRENT_DATE`, `NOW()::date`, `ts::date`, `DATE(ts)`, `date_trunc('day', ts)` in routes and metrics SQL becomes business-correct *without touching the queries*. Explicit `AT TIME ZONE 'America/Chicago'` expressions are unaffected (they're absolute) — leave them; they now read as documentation.
|
||||
|
||||
- [ ] Run `ALTER DATABASE`; verify with `SHOW timezone` from a fresh `psql` and from the app pool (add a temporary log of `SELECT current_setting('timezone')` on pool connect).
|
||||
- [ ] Belt-and-suspenders: also set it in the pool config in `src/utils/db.js` (`options: '-c TimeZone=America/Chicago'` or a `SET TIME ZONE` on connect) so a DB restore/migration can't silently revert it. Same for the pg pools in `scripts/import/`* and `scripts/forecast/run_forecast.js`.
|
||||
- [ ] Sweep for anything that *wanted* UTC or Berlin semantics: `grep -rn "NOW()\|CURRENT_DATE\|CURRENT_TIMESTAMP" src/ scripts/ db/`. Audit found none — confirm. (Pipeline bookkeeping like `last_calculated >= NOW() - INTERVAL '5 minutes'` is interval-based and unaffected.)
|
||||
- [ ] Check `forecast_engine.py`: any `datetime.now()`/`date.today()` used to anchor horizons should become Chicago-aware (`zoneinfo.ZoneInfo('America/Chicago')`). The engine writes `forecast_date` DATE rows — those must mean business dates.
|
||||
|
||||
**1.2 Set Node process TZ to business time.**
|
||||
Add `TZ=America/Chicago` to the PM2 ecosystem env for `inventory-server` and `dashboard-server` (and the cron wrapper for `full-update.js`).
|
||||
|
||||
Why: (a) fixes defect #7 — node-postgres will materialize DATE columns at *Chicago* midnight, which round-trips through `toISOString().split('T')[0]` to the **same** date (Chicago is west of UTC); (b) makes incidental `new Date()` / `setHours` / `getHours` math business-aligned instead of Berlin.
|
||||
|
||||
- [ ] Add `TZ` to `ecosystem.config.cjs` env blocks; `pm2 reload ecosystem.config.cjs --update-env` (NOT `pm2 restart` — see PM2 env gotcha).
|
||||
- [ ] Even with TZ set, replace the `.toISOString().split('T')[0]` idiom on DATE columns with `::text` in the SQL (cheapest, unambiguous) or a `formatDateCol()` helper — do this opportunistically in Phase 4; the TZ change makes it correct immediately.
|
||||
- [ ] Note: `new Date().toISOString()` still yields UTC instants — fine for instants, never for "today". `businessToday()` from the shared module (Phase 3) is the only sanctioned "today".
|
||||
|
||||
**Verification (Phase 1):**
|
||||
|
||||
- [ ] Before/after at ~7pm ET (the danger window): `SELECT CURRENT_DATE` must equal the Chicago date, not tomorrow.
|
||||
- [ ] `GET /api/dashboard` forecast series: dates must match `SELECT forecast_date::text` directly from PG (defect #7 regression check).
|
||||
- [ ] Run a full `full-update.js` cycle; diff `product_metrics` sales_30d counts for a handful of pids against hand-computed Chicago windows.
|
||||
|
||||
## Phase 2 — Fix the acot-server hour-late bug (biggest visible-number impact)
|
||||
|
||||
All in `dashboard/acot-server/` (= `/var/www/inventory/dashboard/acot-server/`).
|
||||
|
||||
**2.1 `utils/timeUtils.js`:**
|
||||
|
||||
- [ ] Replace `DB_TIMEZONE = 'UTC-05:00'` with `DB_TIMEZONE = 'America/Chicago'`.
|
||||
- [ ] `toDatabaseSqlString()`: `normalized.setZone(DB_TIMEZONE).toFormat(...)` — **delete `keepLocalTime: true*`*. 1:00am ET now correctly serializes as `00:00:00` (Central).
|
||||
- [ ] `parseBusinessDate()` / `formatMySQLDate()`: same zone constant; they become DST-correct automatically.
|
||||
- [ ] `getDayEnd()`: switch to half-open — return next day-start; change `getTimeRangeConditions` to emit `date_placed >= ? AND date_placed < ?`. (Eliminates the −1ms endpoint and any boundary-order ambiguity.)
|
||||
- [ ] Keep `BUSINESS_DAY_START_HOUR = 1` ET — it's the same convention; the *conversion* was the bug.
|
||||
|
||||
**2.2 Daily bucketing in routes — remove the hand-rolled hour shift:**
|
||||
|
||||
- [ ] `routes/operations-metrics.js` (~~184–214) and `routes/employee-metrics.js` (~~324–351): `DATE_FORMAT(DATE_SUB(col, INTERVAL 1 HOUR), '%Y-%m-%d')` → `DATE(col)` for `pt.createddate`, `o.date_shipped`, `tc.TimeStamp` (all Central literals — verified). Update the misleading "Eastern timezone" comments.
|
||||
- [ ] Where the JS then re-parses those date strings (`DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE })`) keep zone ET only for *labeling*; the string itself is already the business date — pass it through.
|
||||
|
||||
**2.3 `routes/events.js`:**
|
||||
|
||||
- [ ] Line ~639: `DateTime.fromSQL(value, { zone: 'UTC-05:00' })` → `{ zone: 'America/Chicago' }` (or route through `parseBusinessDate`).
|
||||
- [ ] Hourly chart (today/yesterday): currently `HOUR(date_placed)` returns **Central** hours; office reads the axis as Eastern. Convert for display: emit the business timestamp and format hours in ET on the frontend, or `HOUR(CONVERT_TZ(date_placed,'America/Chicago','America/New_York'))` if the MySQL tz tables are loaded (verify with `SELECT CONVERT_TZ(NOW(),'America/Chicago','America/New_York')`; fall back to `ADDTIME(date_placed, '01:00:00')` — ET = CT + 1h always). Same for the `currentHour` source.
|
||||
- [ ] Previous-period custom math (~553–561) is instant-math and stays as-is once bounds are correct.
|
||||
|
||||
**2.4 Routes not on the util:**
|
||||
|
||||
- [ ] `routes/discounts.js` (~62–85): replace zoneless `DateTime.fromISO` + Berlin `startOf('day')`/`endOf('day')` with `getTimeRangeConditions('custom', …)` / business-day helpers.
|
||||
- [ ] `routes/customers.js` (~238–244): `DATE_SUB(NOW(), INTERVAL 3 MONTH)` is a rough trailing window — acceptable; add a comment that `NOW()` here is Central and that's intended. (Or switch to bound params from the util for uniformity.)
|
||||
- [ ] `routes/payroll-metrics.js`: punches must be interpreted as Central. Either (preferred, see 2.5) get strings from the driver and parse with `DateTime.fromSQL(s, { zone: 'America/Chicago' })`, or correct the `'Z'`-mangled Dates explicitly. All pay-period boundary math stays in ET — that's display convention; the parse is what's broken. Re-test: a 7:58am-CT punch must render 8:58am ET and land on the right pay-period day; test an overnight punch (e.g. 00:30 CT).
|
||||
|
||||
**2.5 Driver config — make values unambiguous:**
|
||||
|
||||
- [ ] `db/connection.js`: add `dateStrings: true` (and drop `timezone: 'Z'`). All DATETIME/TIMESTAMP values arrive as literal strings; every consumer parses through the shared helper with zone Chicago. This converts the implicit-zone footgun into a compile-time-visible parse step. Sweep acot-server routes for `instanceof Date` assumptions and `new Date(row.X)` on datetime fields and route them through the parser.
|
||||
|
||||
**Verification (Phase 2):**
|
||||
|
||||
- [ ] Live-DB boundary check: place/find an order with `date_placed` between `00:00` and `01:00` CT (i.e. 1–2am ET); `timeRange=today` must include orders from `00:00:00` CT onward. Before the fix it starts at `01:00:00` CT.
|
||||
- [ ] Compare `/stats?timeRange=yesterday` totals before/after; expect ~the 1am–2am ET hour to migrate between days.
|
||||
- [ ] Employee daily hours for a known day vs the punch list by hand.
|
||||
- [ ] Flush the dashboard Redis cache after deploy (cached responses are keyed by `timeRange` and hold pre-fix numbers).
|
||||
|
||||
## Phase 3 — One shared business-time module (kill the duplicate utils)
|
||||
|
||||
Create `inventory-server/shared/business-time/` (consistent with the shared-lib location and the side-service convention — this is a leaf dependency, not a middle layer). Export, roughly:
|
||||
|
||||
```
|
||||
BUSINESS_TZ = 'America/Chicago' // canonical
|
||||
OFFICE_TZ = 'America/New_York' // display only
|
||||
businessToday() // Chicago calendar date (DateTime)
|
||||
businessDayStart(d) / businessDayRange(d) // [start, nextStart)
|
||||
rangeForPreset(name, now?) // today|yesterday|last7days|...|lastMonth — one vocabulary
|
||||
previousRange(name|range)
|
||||
toMySqlBound(dt) // Central wall-clock 'YYYY-MM-DD HH:mm:ss'
|
||||
parseMySql(s) // zone Chicago → DateTime
|
||||
businessDateOf(instant) // → 'YYYY-MM-DD'
|
||||
formatOffice(dt, fmt) // ET display formatting
|
||||
```
|
||||
|
||||
- [ ] Port `timeUtils.js` consumers (events/operations/employee/discounts/payroll) to it; delete `dashboard/acot-server/utils/timeUtils.js`.
|
||||
- [ ] Port `TimeManager` consumers (klaviyo routes + `events.service.js`, `campaigns.service.js`, `reporting.service.js`, `redis.service.js`) to it; delete `dashboard/utils/time.utils.js`. Klaviyo API bounds stay UTC ISO (that part was right).
|
||||
- [ ] Fix `groupEventsByInterval` while porting: group by `businessDateOf(eventInstant)` (day) and `setZone(OFFICE_TZ)` for hour/week/month — never the event string's own offset.
|
||||
- [ ] One range vocabulary: the union of today's two sets — `today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days, previous7/30/90days, custom`. Both servers accept exactly this list; unknown → 400, not a silent default.
|
||||
- [ ] ESM/CJS: dashboard tree is ESM, `src/` is CJS — publish the module dual (or `.mjs` with a tiny CJS wrapper), same pattern as existing shared lib.
|
||||
- [ ] Klaviyo verification (per Matt): pull one known recent order event and confirm `attributes.datetime` is UTC (`Z`/+00:00) and matches the PG `orders.date` instant. If it ever arrives as ET-naive, add an explicit zone at the single parse point in the module.
|
||||
|
||||
## Phase 4 — inventory-server `src/routes` cleanup (mostly mechanical after Phase 1)
|
||||
|
||||
- [ ] `dashboard.js`: replace `new Date()`/`toISOString().split('T')[0]` "today"/default-range derivations (~309–316, 1041–1043, 1115–1120) with `businessToday()` / date-only strings; emit DATE columns via `::text` (lines ~335, 379, 478, 542, 769–815).
|
||||
- [ ] `analytics.js`: default ranges (~19–24, 180–184) — drop the UTC truncation dance; accept `YYYY-MM-DD` business dates, default via `businessToday()`. `o.date::date` joins become business-correct after Phase 1 — change to the explicit `AT TIME ZONE 'America/Chicago'` idiom anyway in the hot endpoints so they don't regress if session TZ ever changes.
|
||||
- [ ] `orders.js`: `fromDate/toDate` (~~13–45) — treat date-only input as business-day bounds (start of `from`'s business day to start of day after `to`), not UTC midnights; `DATE(date)` windows (~~115–131) fine post-Phase 1.
|
||||
- [ ] `products.js:1006`, `purchase-orders.js:719/772` (UTC-today defaults), `repeat-orders.js`, `newsletter.js`: same two substitutions (business today; `::text` for DATE output). All `CURRENT_DATE` usages are correct after Phase 1 — leave them.
|
||||
- [ ] `metrics-new/*.sql`: window anchors (`CURRENT_DATE - INTERVAL '59 days'` etc., `update_product_metrics.sql:11` `_current_date`) become business-correct via Phase 1 — no edits required; add a header comment to each file stating the session-TZ assumption.
|
||||
- [ ] Standardize `src/utils/dbConnection.js` and the inline config in `src/routes/import.js` to `dateStrings: true` + shared parsing (they currently use `timezone: 'Z'`; consumers are product-text-centric so risk is low, but one convention).
|
||||
- [ ] Import pipeline (`scripts/import/`): keep `adjustDateForMySQL()` for outbound params (it's the correct dynamic approach) — or migrate to `dateStrings: true` + `toMySqlBound()` for symmetry. Either way, inbound DATETIME *reads* must stop being parsed at fixed `-05:00`: audit `purchase-orders.js`/`daily-deals.js` for fields that survive into PG as instants and route them through `parseMySql()`. (`orders` uses `stamp` TIMESTAMP — wire-format follows session tz = Central; same parse rule applies.)
|
||||
|
||||
## Phase 5 — Frontend consolidation
|
||||
|
||||
**Principle: the server owns all boundary math.** The client sends either a named range or date-only strings (`YYYY-MM-DD`, meaning business dates); it never sends `toISOString()` of a browser-local midnight.
|
||||
|
||||
- [x] **Two sanctioned selector kits** — *implemented 2026-06-13* at `inventory/src/components/dashboard/shared/{BusinessRangeSelect,HorizonTabs}.tsx`.
|
||||
1. `BusinessRangeSelect` — the `TIME_RANGES` presets. Migrated StatCards (×2), SalesChart, ProductGrid, KlaviyoCampaigns (all were identical `<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 560–620, 762–763). Server resolves to 1am-ET bounds. Client-side bucket alignment (`setHours(0,…)` ~390/432) should key on server-provided business-date strings instead.
|
||||
- [ ] `MetaCampaigns` / `UserBehaviorDashboard` / `AnalyticsDashboard`: keep numeric `7/14/30/90` where the upstream API is day-granular (GA4/Meta), but render through `HorizonTabs` for visual consistency; label the GA/Meta cards' footnote "calendar days, midnight ET" (platform limitation — accepted deviation, see Phase 6).
|
||||
- [ ] Forecasting `DateRangePickerQuick`: rename presets to "Trailing month / 3 / 6 / 12 months" (they are `addMonths(now, -N)`, not calendar months) or convert to calendar months — decide one; today the label collides with the dashboards' calendar "Last Month".
|
||||
- [ ] Delete `src/components/ui/date-range-picker-narrow.tsx` (zero importers).
|
||||
- [ ] `BlackFridayDashboard`: replace `getUTCFullYear/getUTCMonth` anchors with business-date helpers (a small `businessToday()` util in `src/utils/` — Chicago date via `Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago' })`).
|
||||
- [ ] `lib/dashboard/constants.js`: keep `TIME_RANGES` as the one preset list; `formatDateForInput`/`parseDateFromInput` stay (datetime-local is inherently browser-local) but custom values must be converted to date-only/business params at the request boundary, not `toISOString()`.
|
||||
- [ ] `DateTime.jsx` (dashboard clock): browser-local is fine for a wall clock; pin to `America/New_York` via `toLocaleString` options so a remote viewer sees office time.
|
||||
|
||||
## Phase 6 — External services: document accepted deviations
|
||||
|
||||
These platforms bucket at **midnight ET**, not 1am ET. The 1-hour difference is not worth fighting their APIs; record it instead.
|
||||
|
||||
- [ ] GA4: property TZ = America/New_York; `NdaysAgo` = calendar days midnight ET. Accepted deviation (affects 0:00–1:00am ET traffic attribution only).
|
||||
- [ ] Meta insights: `time_zone: 'America/New_York'` — same deviation, same note.
|
||||
- [ ] Klaviyo: data fetched with exact UTC bounds from our own range math → fully on-convention once Phase 3 lands (plus the UTC-return verification).
|
||||
- [ ] Add a `docs/TIME.md` (or a section in `.claude/CLAUDE.md`) stating: the convention, the approved idioms table, the accepted deviations, and "never hand-roll an hour offset."
|
||||
|
||||
## Rollout order & risk notes
|
||||
|
||||
1. Phase 2 (acot-server fix) is independent of everything — ship first; it corrects the most-watched numbers. Flush Redis after deploy.
|
||||
2. Phase 1 next, off-hours (ideally before 6pm ET so Berlin/Chicago dates agree during the change); pause the auto-update (`touch inventory-server/.pause-auto-update`), apply, run one supervised `full-update.js`, compare metrics row counts, unpause.
|
||||
3. Phases 3–4 together (module + ports), then 5, then 6.
|
||||
4. PM2: env changes need `pm2 reload ecosystem.config.cjs --update-env` (restart won't pick up new env, except dashboard-server which loads dotenv itself).
|
||||
5. DST regression dates for tests: **Nov 1 2026** (fall back) and **Mar 14 2027** (spring forward) — boundary tests for `rangeForPreset('today')`, `toMySqlBound`, and the metrics windows on both.
|
||||
6. Expect small visible shifts in historical dashboard numbers after Phase 2 (the 1am–2am ET hour moves to its correct day) and after Phase 1 (evening-run metrics windows stop drifting). Worth a heads-up to anyone comparing screenshots.
|
||||
|
||||
## Decisions needed (Matt)
|
||||
|
||||
- [ ] Forecasting quick-presets: trailing (rename) or calendar months? trailing
|
||||
- [ ] Hourly "today" chart axis: display Eastern hours (recommended) — confirm. - yes
|
||||
- [ ] Custom calendar periods (e.g. "March 2026") = business month (Mar 1 1am ET → Apr 1 1am ET, recommended) — confirm, so FinancialOverview presets and custom agree. yes
|
||||
@@ -211,7 +211,12 @@ async function setupSshTunnel() {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: process.env.PROD_DB_PORT || 3306,
|
||||
timezone: 'Z'
|
||||
// DATETIME columns in this DB store Central wall-clock literals. Return
|
||||
// them as literal strings so consumers parse them explicitly with
|
||||
// parseMySql() (zone America/Chicago) from shared/business-time — never
|
||||
// let the driver guess a zone (the old `timezone: 'Z'` mangled every
|
||||
// value by labeling Central wall-clock as UTC).
|
||||
dateStrings: true
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -230,6 +230,8 @@ router.get('/:cid/orders', async (req, res) => {
|
||||
try {
|
||||
// MySQL-safe equivalent of the Laravel query in the freescout module.
|
||||
// Active = placed OR shipped within the last 3 months.
|
||||
// NOW() here is Central (server TZ) — intended: this is a rough
|
||||
// trailing window, not a business-day boundary.
|
||||
const [ordersRaw] = await connection.execute(
|
||||
`SELECT order_id, order_status, order_type, summary_total,
|
||||
date_placed, ship_method_type, ship_method_tracking,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection } from '../db/connection.js';
|
||||
import { BUSINESS_TZ, businessNow, toMySqlBound } from '../../../shared/business-time/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -55,20 +56,20 @@ const DEFAULTS = {
|
||||
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
||||
};
|
||||
|
||||
// Parse date inputs in BUSINESS time (date-only strings = business dates;
|
||||
// zoned instants keep their instant). Never zoneless — that was Berlin.
|
||||
function parseDate(value, fallback) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = DateTime.fromISO(value);
|
||||
const parsed = DateTime.fromISO(value, { zone: BUSINESS_TZ });
|
||||
if (!parsed.isValid) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatDateForSql(dt) {
|
||||
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
|
||||
}
|
||||
const formatDateForSql = (dt) => toMySqlBound(dt);
|
||||
|
||||
router.get('/promos', async (req, res) => {
|
||||
let connection;
|
||||
@@ -78,11 +79,12 @@ router.get('/promos', async (req, res) => {
|
||||
const releaseConnection = release;
|
||||
|
||||
const { startDate, endDate } = req.query || {};
|
||||
const now = DateTime.now().endOf('day');
|
||||
// Half-open business-day range [start, dayAfterEnd) in Chicago time.
|
||||
const now = businessNow();
|
||||
const defaultStart = now.minus({ years: 3 }).startOf('day');
|
||||
|
||||
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
|
||||
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
|
||||
const parsedEnd = (endDate ? parseDate(endDate, now) : now).startOf('day').plus({ days: 1 });
|
||||
|
||||
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
|
||||
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
|
||||
@@ -110,7 +112,7 @@ router.get('/promos', async (req, res) => {
|
||||
) u ON u.discount_code = p.promo_id
|
||||
WHERE p.date_start IS NOT NULL
|
||||
AND p.date_end IS NOT NULL
|
||||
AND NOT (p.date_end < ? OR p.date_start > ?)
|
||||
AND NOT (p.date_end < ? OR p.date_start >= ?)
|
||||
AND p.store = 1
|
||||
AND p.date_start >= '2010-01-01'
|
||||
ORDER BY p.promo_id DESC
|
||||
@@ -343,10 +345,11 @@ router.post('/simulate', async (req, res) => {
|
||||
pointsConfig = {}
|
||||
} = req.body || {};
|
||||
|
||||
const endDefault = DateTime.now();
|
||||
// Half-open business-day bounds [startOfDay, dayAfterEnd) in Chicago time.
|
||||
const endDefault = businessNow();
|
||||
const startDefault = endDefault.minus({ months: 6 });
|
||||
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
|
||||
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
||||
const endDt = parseDate(dateRange.end, endDefault).startOf('day').plus({ days: 1 });
|
||||
|
||||
const shipCountry = filters.shipCountry || 'US';
|
||||
const promoIds = Array.from(
|
||||
@@ -463,7 +466,7 @@ router.post('/simulate', async (req, res) => {
|
||||
AND o.order_status >= 20
|
||||
AND o.ship_method_selected <> 'holdit'
|
||||
AND o.ship_country = ?
|
||||
AND o.date_placed BETWEEN ? AND ?
|
||||
AND o.date_placed >= ? AND o.date_placed < ?
|
||||
${promoExistsClause}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import { getTimeRangeConditions, _internal as timeHelpers } from '../utils/timeUtils.js';
|
||||
import {
|
||||
getTimeRangeConditions,
|
||||
customRange,
|
||||
previousRange,
|
||||
parseMySql,
|
||||
BUSINESS_TZ,
|
||||
} from '../../../shared/business-time/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
|
||||
// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
|
||||
// them as strings (dateStrings: true). Parse explicitly in business time.
|
||||
const punchTime = (punch) => {
|
||||
const dt = parseMySql(punch.TimeStamp);
|
||||
return dt ? dt.toJSDate() : new Date(NaN);
|
||||
};
|
||||
|
||||
// Punch types from the database
|
||||
const PUNCH_TYPES = {
|
||||
OUT: 0,
|
||||
@@ -40,7 +53,7 @@ function calculateHoursFromPunches(punches) {
|
||||
|
||||
byEmployee.forEach((employeePunches, employeeId) => {
|
||||
// Sort by timestamp
|
||||
employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
|
||||
employeePunches.sort((a, b) => punchTime(a) - punchTime(b));
|
||||
|
||||
let hours = 0;
|
||||
let breakHours = 0;
|
||||
@@ -48,24 +61,24 @@ function calculateHoursFromPunches(punches) {
|
||||
let breakStart = null;
|
||||
|
||||
employeePunches.forEach(punch => {
|
||||
const punchTime = new Date(punch.TimeStamp);
|
||||
const punchAt = punchTime(punch);
|
||||
|
||||
switch (punch.PunchType) {
|
||||
case PUNCH_TYPES.IN:
|
||||
currentIn = punchTime;
|
||||
currentIn = punchAt;
|
||||
break;
|
||||
case PUNCH_TYPES.OUT:
|
||||
if (currentIn) {
|
||||
hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours
|
||||
hours += (punchAt - currentIn) / (1000 * 60 * 60); // Convert ms to hours
|
||||
currentIn = null;
|
||||
}
|
||||
break;
|
||||
case PUNCH_TYPES.BREAK_START:
|
||||
breakStart = punchTime;
|
||||
breakStart = punchAt;
|
||||
break;
|
||||
case PUNCH_TYPES.BREAK_END:
|
||||
if (breakStart) {
|
||||
breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
|
||||
breakHours += (punchAt - breakStart) / (1000 * 60 * 60);
|
||||
breakStart = null;
|
||||
}
|
||||
break;
|
||||
@@ -315,13 +328,13 @@ router.get('/', async (req, res) => {
|
||||
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
|
||||
const weeksInPeriod = periodDays / 7;
|
||||
|
||||
// Get daily trend data for hours
|
||||
// Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues
|
||||
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
||||
// Get daily trend data for hours.
|
||||
// TimeStamp stores Central wall-clock literals; business date = the
|
||||
// Central calendar date, so DATE_FORMAT(col) needs no hour shift.
|
||||
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
||||
const trendQuery = `
|
||||
SELECT
|
||||
DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||
DATE_FORMAT(tc.TimeStamp, '%Y-%m-%d') as date,
|
||||
tc.EmployeeID,
|
||||
tc.TimeStamp,
|
||||
tc.PunchType
|
||||
@@ -337,18 +350,19 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Get daily picking data for trend
|
||||
// Ship-together orders: only count main orders (is_sub = 0 or NULL)
|
||||
// Use DATE_FORMAT for consistent date string format
|
||||
// createddate is a Central wall-clock literal — DATE_FORMAT(col) is the
|
||||
// business date with no hour shift.
|
||||
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||
const pickingTrendQuery = `
|
||||
SELECT
|
||||
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
|
||||
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
|
||||
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
|
||||
FROM picking_ticket pt
|
||||
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||
WHERE ${pickingTrendWhere}
|
||||
AND pt.closeddate IS NOT NULL
|
||||
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
|
||||
ORDER BY date
|
||||
`;
|
||||
|
||||
@@ -376,13 +390,14 @@ router.get('/', async (req, res) => {
|
||||
byDate.get(date).push(row);
|
||||
});
|
||||
|
||||
// Generate all dates in the period range for complete trend data
|
||||
// Generate all business dates in the half-open period [start, end) for
|
||||
// complete trend data. Business date = Chicago calendar date.
|
||||
const allDatesInRange = [];
|
||||
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
|
||||
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
|
||||
const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
|
||||
const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
|
||||
|
||||
let currentDt = startDt;
|
||||
while (currentDt <= endDt) {
|
||||
while (currentDt < endDt) {
|
||||
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
|
||||
currentDt = currentDt.plus({ days: 1 });
|
||||
}
|
||||
@@ -393,7 +408,8 @@ router.get('/', async (req, res) => {
|
||||
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
|
||||
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
|
||||
|
||||
// Parse date string in Eastern timezone to get proper ISO timestamp
|
||||
// The string is already the business date; the zone here is only for
|
||||
// the display timestamp label (office time).
|
||||
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
|
||||
|
||||
return {
|
||||
@@ -646,22 +662,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
try {
|
||||
const prev = previousRange(customRange(startDate, endDate));
|
||||
if (!prev) return null;
|
||||
return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = end.getTime() - start.getTime();
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||
}
|
||||
|
||||
function getPreviousTimeRange(timeRange) {
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
getTimeRangeConditions,
|
||||
formatBusinessDate,
|
||||
getBusinessDayBounds,
|
||||
customRange,
|
||||
previousRange,
|
||||
parseMySql,
|
||||
toMySqlBound,
|
||||
_internal as timeHelpers,
|
||||
} from '../utils/timeUtils.js';
|
||||
} from '../../../shared/business-time/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -83,7 +87,7 @@ router.get('/stats', async (req, res) => {
|
||||
FROM _order
|
||||
WHERE order_status = 15
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
|
||||
`;
|
||||
|
||||
const [cancelledResult] = await connection.execute(cancelledQuery, params);
|
||||
@@ -96,7 +100,7 @@ router.get('/stats', async (req, res) => {
|
||||
ABS(SUM(payment_amount)) as refundTotal
|
||||
FROM order_payment op
|
||||
JOIN _order o ON op.order_id = o.order_id
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
||||
`;
|
||||
|
||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||
@@ -108,7 +112,7 @@ router.get('/stats', async (req, res) => {
|
||||
FROM _order
|
||||
WHERE order_status IN (92, 95, 100)
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
|
||||
`;
|
||||
|
||||
const [shippedResult] = await connection.execute(shippedQuery, params);
|
||||
@@ -129,29 +133,29 @@ router.get('/stats', async (req, res) => {
|
||||
|
||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
||||
|
||||
// Peak hour query - uses selected time range for the card value
|
||||
// Peak hour query - uses selected time range for the card value.
|
||||
// date_placed stores Central wall-clock; the office reads the axis in
|
||||
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
||||
let peakHour = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
const peakHourQuery = `
|
||||
SELECT
|
||||
HOUR(date_placed) as hour,
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY HOUR(date_placed)
|
||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||
ORDER BY count DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const [peakHourResult] = await connection.execute(peakHourQuery, params);
|
||||
if (peakHourResult.length > 0) {
|
||||
const hour = peakHourResult[0].hour;
|
||||
const date = new Date();
|
||||
date.setHours(hour, 0, 0);
|
||||
const hour = parseInt(peakHourResult[0].hour);
|
||||
peakHour = {
|
||||
hour,
|
||||
count: parseInt(peakHourResult[0].count),
|
||||
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
|
||||
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -160,23 +164,27 @@ router.get('/stats', async (req, res) => {
|
||||
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
|
||||
let hourlyOrders = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
// Get hourly counts AND current hour from MySQL to avoid timezone mismatch
|
||||
// Hourly counts in EASTERN display hours (column stores Central
|
||||
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
||||
// expression so the rolling window stays aligned.
|
||||
const hourlyQuery = `
|
||||
SELECT
|
||||
HOUR(date_placed) as hour,
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count,
|
||||
HOUR(NOW()) as currentHour
|
||||
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
|
||||
FROM _order
|
||||
WHERE order_status > 15
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
||||
GROUP BY HOUR(date_placed)
|
||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||
`;
|
||||
|
||||
const [hourlyResult] = await connection.execute(hourlyQuery);
|
||||
|
||||
// Get current hour from MySQL (same timezone as the WHERE clause)
|
||||
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
|
||||
// Current hour in Eastern (fallback computes it directly)
|
||||
const currentHour = hourlyResult.length > 0
|
||||
? parseInt(hourlyResult[0].currentHour)
|
||||
: DateTime.now().setZone(TIMEZONE).hour;
|
||||
|
||||
// Build map of hour -> count
|
||||
const hourCounts = {};
|
||||
@@ -208,7 +216,7 @@ router.get('/stats', async (req, res) => {
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
JOIN product_categories pc ON p.company = pc.cat_id
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
||||
GROUP BY pc.cat_id, pc.name
|
||||
HAVING revenue > 0
|
||||
ORDER BY revenue DESC
|
||||
@@ -233,7 +241,7 @@ router.get('/stats', async (req, res) => {
|
||||
JOIN product_categories pc ON pci.cat_id = pc.cat_id
|
||||
WHERE o.order_status > 15
|
||||
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
||||
AND pc.type IN (10, 20, 11, 21, 12, 13)
|
||||
GROUP BY pc.cat_id, pc.name
|
||||
HAVING revenue > 0
|
||||
@@ -253,7 +261,7 @@ router.get('/stats', async (req, res) => {
|
||||
FROM _order
|
||||
WHERE order_status IN (92, 95, 100)
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'date_shipped')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
|
||||
GROUP BY ship_country, ship_state, ship_method_selected
|
||||
`;
|
||||
|
||||
@@ -425,7 +433,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
WHERE op.payment_amount < 0
|
||||
AND o.order_status > 15
|
||||
AND ${getCherryBoxClauseAliased('o', excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'op.payment_date')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'op.payment_date')}
|
||||
GROUP BY DATE(op.payment_date)
|
||||
ORDER BY DATE(op.payment_date)
|
||||
`;
|
||||
@@ -457,7 +465,7 @@ router.get('/stats/details', async (req, res) => {
|
||||
FROM _order
|
||||
WHERE order_status = 15
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND ${whereClause.replace('date_placed', 'date_cancelled')}
|
||||
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
|
||||
GROUP BY DATE(date_cancelled)
|
||||
ORDER BY DATE(date_cancelled)
|
||||
`;
|
||||
@@ -549,16 +557,10 @@ router.get('/stats/details', async (req, res) => {
|
||||
prevWhereClause = result.whereClause;
|
||||
prevParams = result.params;
|
||||
} else {
|
||||
// Custom date range - go back by the same duration
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
// Custom date range - go back by the same duration (business-day aware)
|
||||
const prev = previousRange(customRange(startDate, endDate));
|
||||
prevWhereClause = 'date_placed >= ? AND date_placed < ?';
|
||||
prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
|
||||
}
|
||||
|
||||
// Get previous period daily data (optionally excludes Cherry Box orders)
|
||||
@@ -576,10 +578,10 @@ router.get('/stats/details', async (req, res) => {
|
||||
const [prevResults] = await connection.execute(prevQuery, prevParams);
|
||||
|
||||
// Create a map for quick lookup of previous period data
|
||||
// (dateStrings: true — DATE columns arrive as 'YYYY-MM-DD' strings)
|
||||
const prevMap = new Map();
|
||||
prevResults.forEach(prev => {
|
||||
const key = new Date(prev.date).toISOString().split('T')[0];
|
||||
prevMap.set(key, prev);
|
||||
prevMap.set(String(prev.date), prev);
|
||||
});
|
||||
|
||||
// For period-to-period comparison, we need to map days by relative position
|
||||
@@ -636,8 +638,8 @@ router.get('/financials', async (req, res) => {
|
||||
|
||||
const formatDebugBound = (value) => {
|
||||
if (!value) return 'n/a';
|
||||
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
|
||||
if (!parsed.isValid) {
|
||||
const parsed = parseMySql(value);
|
||||
if (!parsed) {
|
||||
return `invalid(${value})`;
|
||||
}
|
||||
return parsed.setZone(TIMEZONE).toISO();
|
||||
@@ -771,7 +773,7 @@ router.get('/products', async (req, res) => {
|
||||
FROM order_items oi
|
||||
JOIN _order o ON oi.order_id = o.order_id
|
||||
JOIN products p ON oi.prod_pid = p.pid
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
|
||||
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
||||
GROUP BY p.pid, p.description
|
||||
ORDER BY totalRevenue DESC
|
||||
LIMIT 500
|
||||
@@ -1029,15 +1031,13 @@ function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
|
||||
}
|
||||
|
||||
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
|
||||
const businessDayOffset = BUSINESS_DAY_START_HOUR;
|
||||
// date_change stores Central wall-clock literals; business date = the
|
||||
// Central calendar date, so DATE_FORMAT(col) needs no hour shift.
|
||||
// Optionally join to _order to exclude Cherry Box orders
|
||||
if (excludeCherryBox) {
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
|
||||
'%Y-%m-%d'
|
||||
) as businessDate,
|
||||
DATE_FORMAT(r.date_change, '%Y-%m-%d') as businessDate,
|
||||
SUM(r.sale_amount) as grossSales,
|
||||
SUM(r.refund_amount) as refunds,
|
||||
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
|
||||
@@ -1055,10 +1055,7 @@ function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
|
||||
}
|
||||
return `
|
||||
SELECT
|
||||
DATE_FORMAT(
|
||||
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
|
||||
'%Y-%m-%d'
|
||||
) as businessDate,
|
||||
DATE_FORMAT(date_change, '%Y-%m-%d') as businessDate,
|
||||
SUM(sale_amount) as grossSales,
|
||||
SUM(refund_amount) as refunds,
|
||||
SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees,
|
||||
@@ -1193,22 +1190,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
try {
|
||||
const prev = previousRange(customRange(startDate, endDate));
|
||||
if (!prev) return null;
|
||||
return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = end.getTime() - start.getTime();
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||
}
|
||||
|
||||
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
|
||||
@@ -1221,16 +1209,10 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate,
|
||||
prevWhereClause = result.whereClause;
|
||||
prevParams = result.params;
|
||||
} else {
|
||||
// Custom date range - go back by the same duration
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const duration = end.getTime() - start.getTime();
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
|
||||
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
|
||||
// Custom date range - go back by the same duration (business-day aware)
|
||||
const prev = previousRange(customRange(startDate, endDate));
|
||||
prevWhereClause = 'date_placed >= ? AND date_placed < ?';
|
||||
prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
|
||||
}
|
||||
|
||||
// Previous period query (optionally excludes Cherry Box orders)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import { getTimeRangeConditions } from '../utils/timeUtils.js';
|
||||
import {
|
||||
getTimeRangeConditions,
|
||||
customRange,
|
||||
previousRange,
|
||||
BUSINESS_TZ,
|
||||
} from '../../../shared/business-time/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -170,9 +175,10 @@ router.get('/', async (req, res) => {
|
||||
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
|
||||
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
|
||||
|
||||
// Get daily trend data for picking
|
||||
// Use DATE_FORMAT to get date string in Eastern timezone
|
||||
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
||||
// Get daily trend data for picking.
|
||||
// Columns store Central wall-clock literals, and the business date IS
|
||||
// the Central calendar date — so DATE(col) is the business date with
|
||||
// no hour shift.
|
||||
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||
const pickingTrendQuery = `
|
||||
SELECT
|
||||
@@ -181,22 +187,22 @@ router.get('/', async (req, res) => {
|
||||
pt_agg.piecesPicked
|
||||
FROM (
|
||||
SELECT
|
||||
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
|
||||
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
|
||||
FROM picking_ticket pt
|
||||
WHERE ${pickingTrendWhere}
|
||||
AND pt.closeddate IS NOT NULL
|
||||
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
|
||||
) pt_agg
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
|
||||
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||
FROM picking_ticket pt
|
||||
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||
WHERE ${pickingTrendWhere}
|
||||
AND pt.closeddate IS NOT NULL
|
||||
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
|
||||
) order_counts ON pt_agg.date = order_counts.date
|
||||
ORDER BY pt_agg.date
|
||||
`;
|
||||
@@ -205,13 +211,13 @@ router.get('/', async (req, res) => {
|
||||
const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||
const shippingTrendQuery = `
|
||||
SELECT
|
||||
DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||
DATE_FORMAT(o.date_shipped, '%Y-%m-%d') as date,
|
||||
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||
FROM _order o
|
||||
WHERE ${shippingTrendWhere}
|
||||
AND o.order_status IN (100, 92)
|
||||
GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||
GROUP BY DATE_FORMAT(o.date_shipped, '%Y-%m-%d')
|
||||
ORDER BY date
|
||||
`;
|
||||
|
||||
@@ -239,13 +245,14 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Generate all dates in the period range for complete trend data
|
||||
// Generate all business dates in the half-open period [start, end) for
|
||||
// complete trend data. Business date = Chicago calendar date.
|
||||
const allDatesInRange = [];
|
||||
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
|
||||
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
|
||||
const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
|
||||
const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
|
||||
|
||||
let currentDt = startDt;
|
||||
while (currentDt <= endDt) {
|
||||
while (currentDt < endDt) {
|
||||
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
|
||||
currentDt = currentDt.plus({ days: 1 });
|
||||
}
|
||||
@@ -255,7 +262,8 @@ router.get('/', async (req, res) => {
|
||||
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
|
||||
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 0 };
|
||||
|
||||
// Parse date string in Eastern timezone to get proper ISO timestamp
|
||||
// The string is already the business date; the zone here is only for
|
||||
// the display timestamp label (office time).
|
||||
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
|
||||
|
||||
return {
|
||||
@@ -446,22 +454,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
try {
|
||||
const prev = previousRange(customRange(startDate, endDate));
|
||||
if (!prev) return null;
|
||||
return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = end.getTime() - start.getTime();
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevEnd = new Date(start.getTime() - 1);
|
||||
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||
|
||||
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||
}
|
||||
|
||||
function getPreviousTimeRange(timeRange) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import express from 'express';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getDbConnection, getPoolStatus } from '../db/connection.js';
|
||||
import { parseMySql, toMySqlBound } from '../../../shared/business-time/index.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const TIMEZONE = 'America/New_York';
|
||||
|
||||
// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
|
||||
// them as strings (dateStrings: true). Parse in business time, compare/display
|
||||
// in ET (pay-period boundaries are an ET display convention).
|
||||
const punchDateTime = (punch) => {
|
||||
const dt = parseMySql(punch.TimeStamp);
|
||||
return dt ? dt.setZone(TIMEZONE) : null;
|
||||
};
|
||||
|
||||
// Punch types from the database
|
||||
const PUNCH_TYPES = {
|
||||
OUT: 0,
|
||||
@@ -108,17 +117,17 @@ function calculateHoursByWeek(punches, payPeriod) {
|
||||
|
||||
byEmployee.forEach((employeeData) => {
|
||||
// Sort punches by timestamp
|
||||
employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
|
||||
employeeData.punches.sort((a, b) => (punchDateTime(a)?.toMillis() ?? 0) - (punchDateTime(b)?.toMillis() ?? 0));
|
||||
|
||||
// Calculate hours for each week
|
||||
const week1Punches = employeeData.punches.filter(p => {
|
||||
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
|
||||
return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
|
||||
const dt = punchDateTime(p);
|
||||
return dt && dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
|
||||
});
|
||||
|
||||
const week2Punches = employeeData.punches.filter(p => {
|
||||
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
|
||||
return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
|
||||
const dt = punchDateTime(p);
|
||||
return dt && dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
|
||||
});
|
||||
|
||||
const week1Hours = calculateHoursFromPunches(week1Punches);
|
||||
@@ -205,7 +214,7 @@ function calculateHoursFromPunches(punches) {
|
||||
let breakStart = null;
|
||||
|
||||
punches.forEach(punch => {
|
||||
const punchTime = new Date(punch.TimeStamp);
|
||||
const punchTime = punchDateTime(punch)?.toJSDate() ?? new Date(NaN);
|
||||
|
||||
switch (punch.PunchType) {
|
||||
case PUNCH_TYPES.IN:
|
||||
@@ -283,9 +292,11 @@ router.get('/', async (req, res) => {
|
||||
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||
try {
|
||||
|
||||
// Build query for the pay period
|
||||
const periodStart = payPeriod.start.toJSDate();
|
||||
const periodEnd = payPeriod.end.toJSDate();
|
||||
// Build query for the pay period. Bounds are serialized to Central
|
||||
// wall-clock strings (the column's literal zone), half-open
|
||||
// [start, start + 14 days).
|
||||
const periodStart = toMySqlBound(payPeriod.start);
|
||||
const periodEnd = toMySqlBound(payPeriod.start.plus({ days: 14 }));
|
||||
|
||||
const timeclockQuery = `
|
||||
SELECT
|
||||
@@ -296,7 +307,7 @@ router.get('/', async (req, res) => {
|
||||
e.lastname
|
||||
FROM timeclock tc
|
||||
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
||||
WHERE tc.TimeStamp >= ? AND tc.TimeStamp <= ?
|
||||
WHERE tc.TimeStamp >= ? AND tc.TimeStamp < ?
|
||||
AND e.hidden = 0
|
||||
AND e.disabled = 0
|
||||
ORDER BY tc.EmployeeID, tc.TimeStamp
|
||||
@@ -322,8 +333,8 @@ router.get('/', async (req, res) => {
|
||||
// Get previous pay period data for comparison
|
||||
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
|
||||
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
|
||||
prevPayPeriod.start.toJSDate(),
|
||||
prevPayPeriod.end.toJSDate(),
|
||||
toMySqlBound(prevPayPeriod.start),
|
||||
toMySqlBound(prevPayPeriod.start.plus({ days: 14 })),
|
||||
]);
|
||||
|
||||
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
|
||||
|
||||
@@ -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 { CampaignsService } from '../../services/klaviyo/campaigns.service.js';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
|
||||
export function createCampaignsRouter(apiKey, apiRevision, redis) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import { EventsService } from '../../services/klaviyo/events.service.js';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
import { RedisService } from '../../services/klaviyo/redis.service.js';
|
||||
import { requirePermission } from '../../../shared/auth/middleware.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import { ReportingService } from '../../services/klaviyo/reporting.service.js';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
|
||||
export function createReportingRouter(apiKey, apiRevision, redis) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
|
||||
export class CampaignsService {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// Reads short-circuit to null when the client isn't ready; writes are no-ops.
|
||||
// Same "Redis hiccup → fall through to upstream" behavior as before.
|
||||
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
|
||||
export class RedisService {
|
||||
constructor(redis) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { TimeManager } from '../../../shared/business-time/index.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
|
||||
const METRIC_IDS = {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# TIME.md — the business-time convention
|
||||
|
||||
Adopted 2026-06-12 (see `TIME_UNIFICATION_PLAN.md` at the repo root for the
|
||||
audit and rollout). **Never hand-roll an hour offset.**
|
||||
|
||||
## The convention
|
||||
|
||||
**A business day runs 1:00am Eastern → 1:00am Eastern the next day.**
|
||||
|
||||
Because 1am Eastern == midnight Central year-round (both observe US DST),
|
||||
this is identical to:
|
||||
|
||||
> **Business date = the America/Chicago calendar date.**
|
||||
|
||||
- `BUSINESS_TZ = 'America/Chicago'` — canonical, used for all boundary math.
|
||||
- `OFFICE_TZ = 'America/New_York'` — display only (the office reads ET).
|
||||
|
||||
The single source of truth is `shared/business-time/index.js`
|
||||
(`@inventory/shared/business-time`). The frontend mirror for the few
|
||||
client-side needs is `inventory/src/utils/businessTime.ts`.
|
||||
|
||||
## Approved idioms
|
||||
|
||||
| Context | Correct idiom |
|
||||
| --- | --- |
|
||||
| PG `timestamptz` → business date | `(ts AT TIME ZONE 'America/Chicago')::date`, or plain `ts::date` / `CURRENT_DATE` (the session TZ is pinned to America/Chicago in every pool config AND as the `inventory_db` database default) |
|
||||
| MySQL DATETIME literal (stores **Central wall-clock**) → business date | `DATE(col)` / `DATE_FORMAT(col, '%Y-%m-%d')` — **no hour shift** |
|
||||
| MySQL WHERE bounds for business days | Central wall-clock strings via `toMySqlBound()`: `col >= ? AND col < ?` |
|
||||
| MySQL hour-of-day for the office | `HOUR(ADDTIME(col, '01:00:00'))` — ET = CT + 1h year-round |
|
||||
| Luxon | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset (wrong every winter) |
|
||||
| Intervals | **Half-open** `[start, nextStart)`. No `-1 minute` / `-1 ms` endpoints (legacy exception: the `TimeManager` compat class keeps an inclusive −1ms end for its existing consumers) |
|
||||
| "Today" in JS | `businessTodayStr()` / `businessToday()` from the shared module — never `new Date().toISOString().split('T')[0]` (UTC) |
|
||||
| node-postgres DATE columns → string | `formatDateCol(value)` (local-time formatting round-trips correctly) or `::text` in SQL — never `.toISOString().split('T')[0]` (UTC shifts a day) |
|
||||
| mysql2 driver config | `dateStrings: true` + parse with `parseMySql()` (zone Chicago). The import pipeline instead pins a **dynamic** Chicago offset (`currentChicagoOffset()` in `scripts/import-from-prod.js`) |
|
||||
| Client → server | Named range presets, or **date-only strings** (`YYYY-MM-DD` = business dates). Never `toISOString()` of a browser-local midnight |
|
||||
|
||||
## Range vocabulary (both servers, one list)
|
||||
|
||||
`today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth,
|
||||
last7days, last30days, last90days, previous7days, previous30days,
|
||||
previous90days, custom`
|
||||
|
||||
`custom` takes `startDate`/`endDate` as date-only business dates (preferred)
|
||||
or zoned ISO instants (used as-is, end exclusive). Calendar periods
|
||||
("March 2026") mean the business month: Mar 1 1am ET → Apr 1 1am ET.
|
||||
|
||||
## Accepted deviations (documented, not fought)
|
||||
|
||||
- **GA4**: property TZ = America/New_York; `NdaysAgo` = calendar days,
|
||||
midnight ET. Affects only 0:00–1:00am ET traffic attribution.
|
||||
- **Meta insights**: `time_zone: 'America/New_York'` — same 1-hour deviation,
|
||||
same note.
|
||||
- **Klaviyo**: fetched with exact UTC bounds computed from our own range math
|
||||
— fully on-convention. API returns UTC datetimes.
|
||||
- **Pay periods** (payroll dashboard): 14-day periods aligned to **midnight
|
||||
ET** Sundays — an HR display convention, intentionally not 1am-ET days.
|
||||
|
||||
## Infrastructure pins (belt-and-suspenders)
|
||||
|
||||
1. `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` (database default).
|
||||
2. Every pg Pool passes `options: '-c TimeZone=America/Chicago'`
|
||||
(`src/utils/db.js`, `shared/db/pg.js`, `scripts/metrics-new/utils/db.js`,
|
||||
`scripts/calculate-metrics-new.js`, `scripts/import/utils.js`).
|
||||
3. Node processes run with `TZ=America/Chicago`
|
||||
(`/var/www/ecosystem.config.cjs` env blocks; `scripts/full-update.js` and
|
||||
`scripts/forecast/run_forecast.js` set it themselves for cron contexts).
|
||||
4. `forecast_engine.py` anchors on `business_today()` (zoneinfo Chicago).
|
||||
Generated
+10
@@ -21,6 +21,7 @@
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"luxon": "^3.7.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.12.0",
|
||||
"openai": "^6.0.0",
|
||||
@@ -3658,6 +3659,15 @@
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"luxon": "^3.7.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.12.0",
|
||||
"openai": "^6.0.0",
|
||||
|
||||
@@ -140,6 +140,12 @@ console.log('DB Connection Info:', {
|
||||
password: (dbConfig.password || dbConfig.connectionString) ? '****' : 'MISSING' // Only show if credentials exist
|
||||
});
|
||||
|
||||
// Pin the session timezone to business time (see docs/TIME.md) — window
|
||||
// anchors (CURRENT_DATE etc.) in the metrics SQL must mean Chicago dates.
|
||||
if (!dbConfig.options) {
|
||||
dbConfig.options = '-c TimeZone=America/Chicago';
|
||||
}
|
||||
|
||||
const pool = new Pool(dbConfig);
|
||||
|
||||
const getConnection = () => {
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,18 @@ import json
|
||||
import time
|
||||
import logging
|
||||
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 pandas as pd
|
||||
@@ -740,7 +752,7 @@ def batch_load_product_data(conn, products):
|
||||
GROUP BY pid
|
||||
"""
|
||||
adf = execute_query(conn, arrival_sql, [preorder_pids])
|
||||
today = date.today()
|
||||
today = business_today()
|
||||
for _, row in adf.iterrows():
|
||||
pid = int(row['pid'])
|
||||
fa = row['future_arrival']
|
||||
@@ -948,7 +960,7 @@ def forecast_mature(product, history_df):
|
||||
hist['snapshot_date'] = pd.to_datetime(hist['snapshot_date'])
|
||||
hist = hist.set_index('snapshot_date')['units_sold']
|
||||
full_index = pd.date_range(
|
||||
end=pd.Timestamp(date.today() - timedelta(days=1)),
|
||||
end=pd.Timestamp(business_today() - timedelta(days=1)),
|
||||
periods=EXP_SMOOTHING_WINDOW, freq='D')
|
||||
series = hist.reindex(full_index, fill_value=0.0).values.astype(float)
|
||||
|
||||
@@ -1070,7 +1082,7 @@ def generate_all_forecasts(conn, curves_df, dow_indices, monthly_indices=None,
|
||||
log.info("Batch loading product data...")
|
||||
batch_data = batch_load_product_data(conn, products)
|
||||
|
||||
today = date.today()
|
||||
today = business_today()
|
||||
forecast_dates = [today + timedelta(days=i) for i in range(FORECAST_HORIZON_DAYS)]
|
||||
|
||||
# Pre-compute DOW and seasonal multipliers for each forecast date.
|
||||
@@ -1676,7 +1688,7 @@ def backfill_accuracy_data(conn, backfill_days=30):
|
||||
# Batch load product data for per-product scaling
|
||||
batch_data = batch_load_product_data(conn, active)
|
||||
|
||||
today = date.today()
|
||||
today = business_today()
|
||||
backfill_start = today - timedelta(days=backfill_days)
|
||||
|
||||
# Create a synthetic run entry
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* /var/www/inventory/.env (or current process env).
|
||||
*/
|
||||
|
||||
// Run in business time (see docs/TIME.md); the spawned Python engine and any
|
||||
// incidental Date math inherit it.
|
||||
process.env.TZ = process.env.TZ || 'America/Chicago';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Run the whole update cycle in business time (see docs/TIME.md). The cron
|
||||
// that invokes this script does not set TZ; child scripts inherit it.
|
||||
process.env.TZ = process.env.TZ || 'America/Chicago';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
@@ -23,6 +23,17 @@ const IMPORT_STOCK_SNAPSHOTS = true;
|
||||
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
||||
|
||||
// SSH configuration
|
||||
// Current UTC offset of America/Chicago ('-05:00' or '-06:00'), DST-aware.
|
||||
function currentChicagoOffset() {
|
||||
const tzName = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Chicago',
|
||||
timeZoneName: 'longOffset',
|
||||
})
|
||||
.formatToParts(new Date())
|
||||
.find((p) => p.type === 'timeZoneName').value; // e.g. 'GMT-05:00'
|
||||
return tzName.replace('GMT', '') || '+00:00';
|
||||
}
|
||||
|
||||
const sshConfig = {
|
||||
ssh: {
|
||||
host: process.env.PROD_SSH_HOST,
|
||||
@@ -40,7 +51,12 @@ const sshConfig = {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: process.env.PROD_DB_PORT || 3306,
|
||||
timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
|
||||
// DATETIMEs on this server store Central wall-clock. Use the CURRENT
|
||||
// Chicago offset (DST-aware) so both inbound parsing and outbound
|
||||
// serialization are correct in winter (CST=-06:00) and summer
|
||||
// (CDT=-05:00). adjustDateForMySQL() in utils.js remains as a runtime
|
||||
// safety net (its correction is 0 when this offset matches the server).
|
||||
timezone: currentChicagoOffset(),
|
||||
},
|
||||
localDbConfig: {
|
||||
// PostgreSQL config for local
|
||||
|
||||
@@ -58,7 +58,12 @@ async function setupConnections(sshConfig) {
|
||||
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
|
||||
);
|
||||
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
|
||||
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
|
||||
// Driver offset from the actual config (e.g. '-06:00' in winter, '-05:00'
|
||||
// in summer — set dynamically in import-from-prod.js).
|
||||
const tzMatch = /^([+-])(\d{2}):(\d{2})$/.exec(sshConfig.prodDbConfig.timezone || '');
|
||||
const driverOffsetMs = tzMatch
|
||||
? (tzMatch[1] === '-' ? -1 : 1) * (Number(tzMatch[2]) * 3600 + Number(tzMatch[3]) * 60) * 1000
|
||||
: -5 * 3600 * 1000;
|
||||
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
|
||||
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
|
||||
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
|
||||
@@ -79,8 +84,13 @@ async function setupConnections(sshConfig) {
|
||||
}
|
||||
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
|
||||
|
||||
// Setup PostgreSQL connection pool for local
|
||||
const localPool = new Pool(sshConfig.localDbConfig);
|
||||
// Setup PostgreSQL connection pool for local.
|
||||
// Pin the session timezone to business time (see docs/TIME.md) so import
|
||||
// writes and any CURRENT_DATE/NOW()::date logic mean Chicago dates.
|
||||
const localPool = new Pool({
|
||||
options: '-c TimeZone=America/Chicago',
|
||||
...sshConfig.localDbConfig,
|
||||
});
|
||||
|
||||
// Test the PostgreSQL connection
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates and updates aggregated metrics per brand.
|
||||
-- Dependencies: product_metrics, products, calculate_status table.
|
||||
-- Frequency: Daily (after product_metrics update).
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
|
||||
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
|
||||
-- Frequency: Daily (after product_metrics update).
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates and updates aggregated metrics per vendor.
|
||||
-- Dependencies: product_metrics, products, purchase_orders, calculate_status table.
|
||||
-- Frequency: Daily (after product_metrics update).
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates and updates daily aggregated product data.
|
||||
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
|
||||
-- aggregates that don't match source tables after backfills), and always
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
|
||||
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
|
||||
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates metrics that don't need hourly updates, like ABC class
|
||||
-- and average lead time.
|
||||
-- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
|
||||
-- (pinned in the pool config and as the inventory_db database default).
|
||||
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
|
||||
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
|
||||
-- Description: Calculates and updates the main product_metrics table based on current data
|
||||
-- and aggregated daily snapshots. Uses UPSERT for idempotency.
|
||||
-- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status.
|
||||
|
||||
@@ -10,6 +10,9 @@ const dbConfig = {
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
ssl: process.env.DB_SSL === 'true',
|
||||
// Pin the session timezone to business time (see docs/TIME.md) — the
|
||||
// metrics SQL window anchors (CURRENT_DATE etc.) must mean Chicago dates.
|
||||
options: '-c TimeZone=America/Chicago',
|
||||
// Add performance optimizations
|
||||
max: 10, // connection pool max size
|
||||
idleTimeoutMillis: 30000,
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
// Shared business-time module — the single source of truth for the time
|
||||
// convention (see docs/TIME.md and TIME_UNIFICATION_PLAN.md).
|
||||
//
|
||||
// THE CONVENTION: a business day runs 1:00am Eastern → 1:00am Eastern the next
|
||||
// day. Because 1am Eastern == midnight Central year-round (both observe US
|
||||
// DST), this is identical to: business date = the America/Chicago calendar
|
||||
// date. All boundary math here therefore uses Chicago days; Eastern exists
|
||||
// only for display formatting.
|
||||
//
|
||||
// MySQL DATETIME columns on 192.168.1.5 store Central wall-clock literals, so
|
||||
// toMySqlBound()/parseMySql() convert to/from Chicago wall-clock strings with
|
||||
// no hour shift. Never hand-roll an hour offset and never use a fixed
|
||||
// 'UTC-05:00' zone (wrong every winter).
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export const BUSINESS_TZ = 'America/Chicago'; // canonical
|
||||
export const OFFICE_TZ = 'America/New_York'; // display only
|
||||
|
||||
const MYSQL_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
|
||||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing / coercion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Coerce any supported input (DateTime, Date, millis, ISO string, SQL string)
|
||||
* to a luxon DateTime in the business zone. Date-only strings are interpreted
|
||||
* as business dates (Chicago midnight). Zoned ISO strings keep their instant.
|
||||
* Returns null for unparseable input.
|
||||
*/
|
||||
export function toBusinessTime(value) {
|
||||
if (value == null || value === '') return null;
|
||||
|
||||
if (DateTime.isDateTime(value)) return value.setZone(BUSINESS_TZ);
|
||||
if (value instanceof Date) return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
|
||||
if (typeof value === 'number') return DateTime.fromMillis(value).setZone(BUSINESS_TZ);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
let dt = DateTime.fromISO(value, { zone: BUSINESS_TZ, setZone: false });
|
||||
if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: BUSINESS_TZ });
|
||||
return dt.isValid ? dt : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True if the input is a date-only string ('YYYY-MM-DD'). */
|
||||
export const isDateOnly = (value) => typeof value === 'string' && DATE_ONLY_RE.test(value);
|
||||
|
||||
/**
|
||||
* Parse a MySQL DATETIME/TIMESTAMP literal (Central wall-clock) into a
|
||||
* DateTime in the business zone. Returns null for empty / zero dates.
|
||||
*/
|
||||
export function parseMySql(value) {
|
||||
if (!value || value === '0000-00-00 00:00:00' || value === '0000-00-00') return null;
|
||||
if (value instanceof Date) {
|
||||
// Defensive: a driver not configured with dateStrings handed us a Date.
|
||||
// Its instant depends on that driver's timezone config — pass it through
|
||||
// as an instant rather than guessing.
|
||||
return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
|
||||
}
|
||||
const dt = DateTime.fromSQL(String(value), { zone: BUSINESS_TZ });
|
||||
return dt.isValid ? dt : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an instant to a MySQL bound: Central wall-clock
|
||||
* 'YYYY-MM-DD HH:mm:ss'. Use with half-open intervals: col >= ? AND col < ?.
|
||||
*/
|
||||
export function toMySqlBound(value) {
|
||||
const dt = toBusinessTime(value);
|
||||
if (!dt) throw new Error('toMySqlBound: invalid datetime input');
|
||||
return dt.toFormat(MYSQL_DATETIME_FORMAT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Today" and day boundaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Now, as a DateTime in the business zone. */
|
||||
export const businessNow = () => DateTime.now().setZone(BUSINESS_TZ);
|
||||
|
||||
/** Start of the current business day (Chicago midnight = 1am Eastern). */
|
||||
export const businessToday = () => businessNow().startOf('day');
|
||||
|
||||
/** The current business date as 'YYYY-MM-DD'. The only sanctioned "today". */
|
||||
export const businessTodayStr = () => businessNow().toISODate();
|
||||
|
||||
/** Start of the business day containing the given instant/date. */
|
||||
export function businessDayStart(value = businessNow()) {
|
||||
const dt = toBusinessTime(value);
|
||||
if (!dt) throw new Error('businessDayStart: invalid datetime input');
|
||||
return dt.startOf('day');
|
||||
}
|
||||
|
||||
/** Half-open range [start, nextStart) of the business day containing value. */
|
||||
export function businessDayRange(value = businessNow()) {
|
||||
const start = businessDayStart(value);
|
||||
return { start, end: start.plus({ days: 1 }) };
|
||||
}
|
||||
|
||||
/** Business date ('YYYY-MM-DD') of an instant. */
|
||||
export function businessDateOf(value) {
|
||||
const dt = toBusinessTime(value);
|
||||
return dt ? dt.toISODate() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a node-postgres DATE-column value as 'YYYY-MM-DD'.
|
||||
*
|
||||
* node-postgres materializes DATE columns as a JS Date at process-local
|
||||
* midnight, so formatting in LOCAL time round-trips to the same calendar date
|
||||
* regardless of the process zone. (The old .toISOString().split('T')[0] idiom
|
||||
* formatted in UTC, which emitted the PREVIOUS day for any zone east of UTC.)
|
||||
* Strings (e.g. from ::text casts) pass through.
|
||||
*/
|
||||
export function formatDateCol(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') return value.slice(0, 10);
|
||||
if (value instanceof Date) {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(value.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return businessDateOf(value);
|
||||
}
|
||||
|
||||
/** Format an instant in office (Eastern) time for display. */
|
||||
export function formatOffice(value, fmt = 'LLL d, yyyy h:mm a') {
|
||||
const dt = toBusinessTime(value);
|
||||
return dt ? dt.setZone(OFFICE_TZ).toFormat(fmt) : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Named range presets — ONE vocabulary for every server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RANGE_PRESETS = [
|
||||
'today',
|
||||
'yesterday',
|
||||
'twoDaysAgo',
|
||||
'thisWeek',
|
||||
'lastWeek',
|
||||
'thisMonth',
|
||||
'lastMonth',
|
||||
'last7days',
|
||||
'last30days',
|
||||
'last90days',
|
||||
'previous7days',
|
||||
'previous30days',
|
||||
'previous90days',
|
||||
'custom',
|
||||
];
|
||||
|
||||
export const isKnownRange = (name) => RANGE_PRESETS.includes(name);
|
||||
|
||||
// Weeks start on Sunday (business convention).
|
||||
function weekStart(dt) {
|
||||
const day = dt.startOf('day');
|
||||
// luxon weekday: 1 = Monday … 7 = Sunday
|
||||
return day.weekday === 7 ? day : day.minus({ days: day.weekday });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a named preset to a half-open range { start, end } of business-zone
|
||||
* DateTimes (end is EXCLUSIVE — the start of the day after the last included
|
||||
* business day). Throws on unknown names; 'custom' must be resolved by the
|
||||
* caller via customRange().
|
||||
*/
|
||||
export function rangeForPreset(name, now = businessNow()) {
|
||||
const current = toBusinessTime(now);
|
||||
if (!current) throw new Error('rangeForPreset: invalid reference time');
|
||||
const todayStart = current.startOf('day');
|
||||
const tomorrowStart = todayStart.plus({ days: 1 });
|
||||
|
||||
switch (name) {
|
||||
case 'today':
|
||||
return { start: todayStart, end: tomorrowStart };
|
||||
case 'yesterday':
|
||||
return { start: todayStart.minus({ days: 1 }), end: todayStart };
|
||||
case 'twoDaysAgo':
|
||||
return { start: todayStart.minus({ days: 2 }), end: todayStart.minus({ days: 1 }) };
|
||||
case 'thisWeek':
|
||||
return { start: weekStart(current), end: tomorrowStart };
|
||||
case 'lastWeek': {
|
||||
const start = weekStart(current).minus({ weeks: 1 });
|
||||
return { start, end: start.plus({ weeks: 1 }) };
|
||||
}
|
||||
case 'thisMonth':
|
||||
return { start: todayStart.startOf('month'), end: tomorrowStart };
|
||||
case 'lastMonth': {
|
||||
const start = todayStart.startOf('month').minus({ months: 1 });
|
||||
return { start, end: start.plus({ months: 1 }) };
|
||||
}
|
||||
case 'last7days':
|
||||
return { start: todayStart.minus({ days: 6 }), end: tomorrowStart };
|
||||
case 'last30days':
|
||||
return { start: todayStart.minus({ days: 29 }), end: tomorrowStart };
|
||||
case 'last90days':
|
||||
return { start: todayStart.minus({ days: 89 }), end: tomorrowStart };
|
||||
case 'previous7days': {
|
||||
const currentStart = todayStart.minus({ days: 6 });
|
||||
return { start: currentStart.minus({ days: 7 }), end: currentStart };
|
||||
}
|
||||
case 'previous30days': {
|
||||
const currentStart = todayStart.minus({ days: 29 });
|
||||
return { start: currentStart.minus({ days: 30 }), end: currentStart };
|
||||
}
|
||||
case 'previous90days': {
|
||||
const currentStart = todayStart.minus({ days: 89 });
|
||||
return { start: currentStart.minus({ days: 90 }), end: currentStart };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown time range: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a custom range to half-open { start, end }. Date-only strings are
|
||||
* business dates: start = that Chicago midnight, end = the day AFTER the end
|
||||
* date (so both endpoints are included as full business days). Zoned ISO
|
||||
* instants are used as-is (end treated as exclusive).
|
||||
*/
|
||||
export function customRange(startInput, endInput) {
|
||||
if (!startInput || !endInput) throw new Error('customRange: both bounds required');
|
||||
const start = isDateOnly(startInput)
|
||||
? DateTime.fromISO(startInput, { zone: BUSINESS_TZ }).startOf('day')
|
||||
: toBusinessTime(startInput);
|
||||
let end = isDateOnly(endInput)
|
||||
? DateTime.fromISO(endInput, { zone: BUSINESS_TZ }).startOf('day').plus({ days: 1 })
|
||||
: toBusinessTime(endInput);
|
||||
if (!start || !start.isValid || !end || !end.isValid) {
|
||||
throw new Error('customRange: invalid bounds');
|
||||
}
|
||||
if (end < start) throw new Error('customRange: end before start');
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous comparable range. Accepts a preset name (uses the conventional
|
||||
* predecessor: today→yesterday, thisWeek→lastWeek, …) or a { start, end }
|
||||
* half-open range (shifts back by its own duration).
|
||||
*/
|
||||
export function previousRange(nameOrRange, now = businessNow()) {
|
||||
if (typeof nameOrRange === 'string') {
|
||||
const map = {
|
||||
today: 'yesterday',
|
||||
yesterday: 'twoDaysAgo',
|
||||
thisWeek: 'lastWeek',
|
||||
thisMonth: 'lastMonth',
|
||||
last7days: 'previous7days',
|
||||
last30days: 'previous30days',
|
||||
last90days: 'previous90days',
|
||||
};
|
||||
const prev = map[nameOrRange];
|
||||
if (prev) return rangeForPreset(prev, now);
|
||||
// Calendar-unit presets without a named predecessor.
|
||||
if (nameOrRange === 'twoDaysAgo') {
|
||||
const { start } = rangeForPreset('twoDaysAgo', now);
|
||||
return { start: start.minus({ days: 1 }), end: start };
|
||||
}
|
||||
if (nameOrRange === 'lastWeek') {
|
||||
const { start } = rangeForPreset('lastWeek', now);
|
||||
return { start: start.minus({ weeks: 1 }), end: start };
|
||||
}
|
||||
if (nameOrRange === 'lastMonth') {
|
||||
const { start } = rangeForPreset('lastMonth', now);
|
||||
return { start: start.minus({ months: 1 }), end: start };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const { start, end } = nameOrRange || {};
|
||||
if (!start || !end) return null;
|
||||
const duration = end.diff(start);
|
||||
return { start: start.minus(duration), end: start };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MySQL query helpers (acot-server style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RANGE_LABELS = {
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
twoDaysAgo: 'Two Days Ago',
|
||||
thisWeek: 'This Week',
|
||||
lastWeek: 'Last Week',
|
||||
thisMonth: 'This Month',
|
||||
lastMonth: 'Last Month',
|
||||
last7days: 'Last 7 Days',
|
||||
last30days: 'Last 30 Days',
|
||||
last90days: 'Last 90 Days',
|
||||
previous7days: 'Previous 7 Days',
|
||||
previous30days: 'Previous 30 Days',
|
||||
previous90days: 'Previous 90 Days',
|
||||
};
|
||||
|
||||
export const getTimeRangeLabel = (timeRange) => RANGE_LABELS[timeRange] || timeRange;
|
||||
|
||||
/** Eastern display date for a range label ('Jun 12, 2026'). */
|
||||
export function formatBusinessDate(value) {
|
||||
const dt = toBusinessTime(value);
|
||||
return dt ? dt.setZone(OFFICE_TZ).toFormat('LLL d, yyyy') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a half-open MySQL WHERE fragment + Central wall-clock bound params
|
||||
* for a named or custom range. The where clause repeats the column for both
|
||||
* bounds so callers re-targeting another column MUST replace globally
|
||||
* (/date_placed/g).
|
||||
*/
|
||||
export function getTimeRangeConditions(timeRange, startDate, endDate) {
|
||||
let range;
|
||||
let label;
|
||||
|
||||
if (timeRange === 'custom' && startDate && endDate) {
|
||||
range = customRange(startDate, endDate);
|
||||
// Display the last *included* business day, not the exclusive endpoint.
|
||||
label = `${formatBusinessDate(range.start)} - ${formatBusinessDate(range.end.minus({ milliseconds: 1 }))}`;
|
||||
} else {
|
||||
const normalized = timeRange || 'today';
|
||||
range = rangeForPreset(normalized);
|
||||
label = getTimeRangeLabel(normalized);
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: 'date_placed >= ? AND date_placed < ?',
|
||||
params: [toMySqlBound(range.start), toMySqlBound(range.end)],
|
||||
range,
|
||||
start: range.start.toJSDate(),
|
||||
end: range.end.toJSDate(), // EXCLUSIVE
|
||||
dateRange: {
|
||||
start: range.start.toUTC().toISO(),
|
||||
end: range.end.toUTC().toISO(), // EXCLUSIVE
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Half-open JS Date bounds for a named range (end exclusive). */
|
||||
export function getBusinessDayBounds(timeRange) {
|
||||
const range = rangeForPreset(timeRange);
|
||||
return { start: range.start.toJSDate(), end: range.end.toJSDate() };
|
||||
}
|
||||
|
||||
/** Parse a MySQL datetime literal to a JS Date (legacy convenience). */
|
||||
export function parseBusinessDate(mysqlDatetime) {
|
||||
const dt = parseMySql(mysqlDatetime);
|
||||
return dt ? dt.toUTC().toJSDate() : null;
|
||||
}
|
||||
|
||||
/** Serialize any instant to a MySQL Central wall-clock string, null-safe. */
|
||||
export function formatMySQLDate(value) {
|
||||
const dt = toBusinessTime(value);
|
||||
return dt ? dt.toFormat(MYSQL_DATETIME_FORMAT) : null;
|
||||
}
|
||||
|
||||
// Legacy helpers kept for consumers that need raw day/week math. getDayEnd
|
||||
// returns the half-open endpoint (next day start); prefer businessDayRange.
|
||||
export const _internal = {
|
||||
getDayStart: (input) => businessDayStart(input ?? businessNow()),
|
||||
getDayEnd: (input) => businessDayStart(input ?? businessNow()).plus({ days: 1 }),
|
||||
getWeekStart: (input) => weekStart(toBusinessTime(input ?? businessNow())),
|
||||
getRangeForTimeRange: (timeRange, now) => rangeForPreset(timeRange, now),
|
||||
BUSINESS_DAY_START_HOUR: 1, // 1am Eastern == midnight Central
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimeManager — compatibility class for the dashboard/klaviyo tree.
|
||||
//
|
||||
// Same method surface as the old dashboard/utils/time.utils.js, with the
|
||||
// defects fixed:
|
||||
// - getDayEnd: was next-start minus 1 MINUTE (a 59.999s hole every day);
|
||||
// now next-start minus 1 millisecond. Kept inclusive (rather than
|
||||
// half-open) because consumers iterate `while (day <= end)` — a half-open
|
||||
// end would add a spurious trailing bucket. Klaviyo API bounds use
|
||||
// less-than(end), so the residual 1ms hole is negligible.
|
||||
// - groupEventsByInterval: grouped in the event string's own zone; now
|
||||
// groups days by business date and hour/week/month in office time.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TimeManager {
|
||||
constructor(dayStartHour = 1) {
|
||||
this.timezone = OFFICE_TZ;
|
||||
this.dayStartHour = dayStartHour; // retained for API compat; 1 == Chicago midnight
|
||||
this.weekStartDay = 7; // Sunday
|
||||
}
|
||||
|
||||
getNow() {
|
||||
return DateTime.now().setZone(this.timezone);
|
||||
}
|
||||
|
||||
toDateTime(date) {
|
||||
if (!date) return null;
|
||||
if (DateTime.isDateTime(date)) return date.setZone(this.timezone);
|
||||
const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
|
||||
if (!dt.isValid) {
|
||||
console.error('[TimeManager] Invalid date input:', date);
|
||||
return null;
|
||||
}
|
||||
return dt.setZone(this.timezone);
|
||||
}
|
||||
|
||||
getDayStart(dt = this.getNow()) {
|
||||
if (!dt.isValid) return this.getNow();
|
||||
return businessDayStart(dt).setZone(this.timezone);
|
||||
}
|
||||
|
||||
getDayEnd(dt = this.getNow()) {
|
||||
if (!dt.isValid) return this.getNow();
|
||||
return businessDayStart(dt).plus({ days: 1 }).minus({ milliseconds: 1 }).setZone(this.timezone);
|
||||
}
|
||||
|
||||
getWeekStart(dt = this.getNow()) {
|
||||
if (!dt.isValid) return this.getNow();
|
||||
return weekStart(toBusinessTime(dt)).setZone(this.timezone);
|
||||
}
|
||||
|
||||
formatForAPI(date) {
|
||||
const dt = this.toDateTime(date);
|
||||
if (!dt || !dt.isValid) {
|
||||
console.error('[TimeManager] Invalid date for API:', date);
|
||||
return null;
|
||||
}
|
||||
return dt.toUTC().toISO();
|
||||
}
|
||||
|
||||
formatForDisplay(date) {
|
||||
const dt = this.toDateTime(date);
|
||||
if (!dt || !dt.isValid) return '';
|
||||
return dt.toFormat('LLL d, yyyy h:mm a');
|
||||
}
|
||||
|
||||
isValidDateRange(start, end) {
|
||||
const startDt = this.toDateTime(start);
|
||||
const endDt = this.toDateTime(end);
|
||||
return startDt && endDt && endDt > startDt;
|
||||
}
|
||||
|
||||
getLastNHours(hours) {
|
||||
const now = this.getNow();
|
||||
return { start: now.minus({ hours }), end: now };
|
||||
}
|
||||
|
||||
getLastNDays(days) {
|
||||
const now = this.getNow();
|
||||
return { start: this.getDayStart(now).minus({ days }), end: this.getDayEnd(now) };
|
||||
}
|
||||
|
||||
getDateRange(period) {
|
||||
if (period === 'custom') {
|
||||
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
|
||||
return null;
|
||||
}
|
||||
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
||||
try {
|
||||
const { start, end } = rangeForPreset(normalized);
|
||||
return {
|
||||
start: start.setZone(this.timezone),
|
||||
end: end.minus({ milliseconds: 1 }).setZone(this.timezone),
|
||||
};
|
||||
} catch {
|
||||
console.warn(`[TimeManager] Unknown period: ${period}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getCustomRange(startDate, endDate) {
|
||||
if (!startDate || !endDate) {
|
||||
console.error('[TimeManager] Custom range requires both start and end dates');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { start, end } = customRange(startDate, endDate);
|
||||
const inclusiveEnd = isDateOnly(endDate) ? end.minus({ milliseconds: 1 }) : end;
|
||||
return { start: start.setZone(this.timezone), end: inclusiveEnd.setZone(this.timezone) };
|
||||
} catch (error) {
|
||||
console.error('[TimeManager] Invalid dates provided for custom range:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getPreviousPeriod(period, now = this.getNow()) {
|
||||
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
||||
const current = (() => {
|
||||
try {
|
||||
return rangeForPreset(normalized, now);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!current) {
|
||||
console.warn(`[TimeManager] No previous period defined for: ${period}`);
|
||||
return null;
|
||||
}
|
||||
const prev =
|
||||
previousRange(normalized, now) ||
|
||||
previousRange(current, now);
|
||||
if (!prev) return null;
|
||||
return {
|
||||
start: prev.start.setZone(this.timezone),
|
||||
end: prev.end.minus({ milliseconds: 1 }).setZone(this.timezone),
|
||||
};
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
|
||||
}
|
||||
|
||||
getRelativeTime(date) {
|
||||
const dt = this.toDateTime(date);
|
||||
if (!dt) return '';
|
||||
return dt.toRelative();
|
||||
}
|
||||
|
||||
groupEventsByInterval(events, interval = 'day', property = null) {
|
||||
if (!events?.length) return [];
|
||||
|
||||
const groupedData = new Map();
|
||||
|
||||
for (const event of events) {
|
||||
const instant = DateTime.fromISO(event.attributes.datetime, { setZone: true });
|
||||
if (!instant.isValid) continue;
|
||||
|
||||
let groupKey;
|
||||
switch (interval) {
|
||||
case 'hour':
|
||||
groupKey = instant.setZone(OFFICE_TZ).startOf('hour').toISO();
|
||||
break;
|
||||
case 'week':
|
||||
groupKey = weekStart(instant.setZone(BUSINESS_TZ)).setZone(OFFICE_TZ).toISO();
|
||||
break;
|
||||
case 'month':
|
||||
groupKey = instant.setZone(BUSINESS_TZ).startOf('month').setZone(OFFICE_TZ).toISO();
|
||||
break;
|
||||
case 'day':
|
||||
default:
|
||||
// Group by BUSINESS date (Chicago calendar day = 1am-ET day rule).
|
||||
groupKey = instant.setZone(BUSINESS_TZ).startOf('day').setZone(OFFICE_TZ).toISO();
|
||||
}
|
||||
|
||||
const existingGroup = groupedData.get(groupKey) || { datetime: groupKey, count: 0, value: 0 };
|
||||
existingGroup.count++;
|
||||
|
||||
if (property) {
|
||||
const props = event.attributes?.event_properties || event.attributes?.properties || {};
|
||||
const value =
|
||||
property === '$value' ? Number(event.attributes?.value || 0) : Number(props[property] || 0);
|
||||
existingGroup.value = (existingGroup.value || 0) + value;
|
||||
}
|
||||
|
||||
groupedData.set(groupKey, existingGroup);
|
||||
}
|
||||
|
||||
return Array.from(groupedData.values()).sort(
|
||||
(a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,8 @@ export function createPool(envPrefix = 'DB', overrides = {}) {
|
||||
max: overrides.max ?? 20,
|
||||
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
|
||||
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
|
||||
// Pin the session timezone to business time (see docs/TIME.md) so date
|
||||
// bucketing can't silently revert if the database default changes.
|
||||
options: overrides.options ?? '-c TimeZone=America/Chicago',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"./logging": "./logging/index.js",
|
||||
"./errors/handler": "./errors/handler.js",
|
||||
"./cors/policy": "./cors/policy.js",
|
||||
"./rate-limit/login": "./rate-limit/login.js"
|
||||
"./rate-limit/login": "./rate-limit/login.js",
|
||||
"./business-time": "./business-time/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -23,6 +24,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0"
|
||||
"pino-http": "^10.3.0",
|
||||
"luxon": "^3.7.2"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Forecasting: summarize sales for products received in a period by brand
|
||||
@@ -15,13 +16,12 @@ router.get('/forecast', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
// Default to last 30 days if no dates provided
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Normalize to date boundaries for consistency
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
// Default to last 30 business days if no dates provided. Params are
|
||||
// date-only business dates ('YYYY-MM-DD'); instants are converted.
|
||||
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
|
||||
const startISO = startDateStr
|
||||
? businessDateOf(startDateStr)
|
||||
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
@@ -177,11 +177,10 @@ router.get('/forecast-v2', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
|
||||
const startISO = startDateStr
|
||||
? businessDateOf(startDateStr)
|
||||
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import db from '../utils/db.js';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to execute queries using the connection pool
|
||||
@@ -306,15 +307,13 @@ router.get('/replenishment/metrics', async (req, res) => {
|
||||
// Reads from product_forecasts table (lifecycle-aware forecasting pipeline).
|
||||
// Falls back to velocity-based projection if forecast table is empty.
|
||||
router.get('/forecast/metrics', async (req, res) => {
|
||||
const today = new Date();
|
||||
const thirtyDaysOut = new Date(today);
|
||||
thirtyDaysOut.setDate(today.getDate() + 30);
|
||||
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate) : today;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut;
|
||||
const startISO = startDate.toISOString().split('T')[0];
|
||||
const endISO = endDate.toISOString().split('T')[0];
|
||||
const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)));
|
||||
// Business dates (Chicago calendar — see docs/TIME.md). Inputs may be
|
||||
// date-only strings or full instants.
|
||||
const startISO = req.query.startDate ? businessDateOf(req.query.startDate) : businessTodayStr();
|
||||
const endISO = req.query.endDate
|
||||
? businessDateOf(req.query.endDate)
|
||||
: businessNow().plus({ days: 30 }).toISODate();
|
||||
const days = Math.max(1, Math.round((Date.parse(endISO) - Date.parse(startISO)) / (1000 * 60 * 60 * 24)));
|
||||
|
||||
try {
|
||||
// Check if product_forecasts has data
|
||||
@@ -331,9 +330,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const { rows: [horizonRow] } = await executeQuery(
|
||||
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
|
||||
);
|
||||
const forecastHorizonISO = horizonRow.max_date instanceof Date
|
||||
? horizonRow.max_date.toISOString().split('T')[0]
|
||||
: horizonRow.max_date;
|
||||
const forecastHorizonISO = formatDateCol(horizonRow.max_date);
|
||||
const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00');
|
||||
const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO;
|
||||
const needsExtrapolation = endISO > forecastHorizonISO;
|
||||
@@ -376,7 +373,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
`, [startISO, clampedEndISO]);
|
||||
|
||||
const dailyForecasts = dailyRows.map(d => ({
|
||||
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
|
||||
date: formatDateCol(d.date),
|
||||
units: parseFloat(d.units) || 0,
|
||||
revenue: parseFloat(d.revenue) || 0,
|
||||
confidence: confidenceLevel,
|
||||
@@ -475,7 +472,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits;
|
||||
|
||||
dailyForecasts.push({
|
||||
date: d.toISOString().split('T')[0],
|
||||
date: formatDateCol(d),
|
||||
units: parseFloat(estUnits.toFixed(1)),
|
||||
revenue: parseFloat(estRevenue.toFixed(2)),
|
||||
confidence: 0, // lower confidence for extrapolated data
|
||||
@@ -539,7 +536,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
`, [startISO, clampedEndISO]);
|
||||
|
||||
const dailyForecastsByPhase = dailyPhaseRows.map(d => ({
|
||||
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
|
||||
date: formatDateCol(d.date),
|
||||
preorder: parseFloat(d.preorder) || 0,
|
||||
launch: parseFloat(d.launch) || 0,
|
||||
decay: parseFloat(d.decay) || 0,
|
||||
@@ -597,11 +594,12 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const dailyRevenue = parseFloat(totals.daily_revenue) || 0;
|
||||
|
||||
const dailyForecasts = [];
|
||||
const fallbackStart = new Date(startISO + 'T00:00:00'); // local midnight — round-trips via formatDateCol
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const d = new Date(fallbackStart);
|
||||
d.setDate(fallbackStart.getDate() + i);
|
||||
dailyForecasts.push({
|
||||
date: d.toISOString().split('T')[0],
|
||||
date: formatDateCol(d),
|
||||
units: parseFloat(dailyUnits.toFixed(1)),
|
||||
revenue: parseFloat(dailyRevenue.toFixed(2)),
|
||||
confidence: 0,
|
||||
@@ -776,7 +774,7 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
`);
|
||||
|
||||
const accuracyTrend = trendRows.map(r => ({
|
||||
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
|
||||
date: formatDateCol(r.run_date),
|
||||
mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null,
|
||||
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
|
||||
bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : null,
|
||||
@@ -796,7 +794,7 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
`);
|
||||
|
||||
const accuracyTrendWeekly = weeklyTrendRows.map(r => ({
|
||||
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
|
||||
date: formatDateCol(r.run_date),
|
||||
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
|
||||
naiveWmape: r.naive_wmape != null ? parseFloat((parseFloat(r.naive_wmape) * 100).toFixed(1)) : null,
|
||||
fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
|
||||
@@ -808,12 +806,8 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
computedAt,
|
||||
daysOfHistory: parseInt(historyInfo.days_of_history) || 0,
|
||||
historyRange: {
|
||||
from: historyInfo.earliest_date instanceof Date
|
||||
? historyInfo.earliest_date.toISOString().split('T')[0]
|
||||
: historyInfo.earliest_date,
|
||||
to: historyInfo.latest_date instanceof Date
|
||||
? historyInfo.latest_date.toISOString().split('T')[0]
|
||||
: historyInfo.latest_date,
|
||||
from: formatDateCol(historyInfo.earliest_date),
|
||||
to: formatDateCol(historyInfo.latest_date),
|
||||
},
|
||||
overall: shapeOverall(overall),
|
||||
overallInclDormant: shapeOverall(overallInclDormant),
|
||||
@@ -1038,10 +1032,9 @@ router.get('/best-sellers', async (req, res) => {
|
||||
// GET /dashboard/year-revenue-estimate
|
||||
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
|
||||
router.get('/year-revenue-estimate', async (req, res) => {
|
||||
const now = new Date();
|
||||
const yearStart = `${now.getFullYear()}-01-01`;
|
||||
const todayISO = now.toISOString().split('T')[0];
|
||||
const yearEndISO = `${now.getFullYear()}-12-31`;
|
||||
const todayISO = businessTodayStr(); // business 'today', not UTC
|
||||
const yearStart = `${todayISO.slice(0, 4)}-01-01`;
|
||||
const yearEndISO = `${todayISO.slice(0, 4)}-12-31`;
|
||||
|
||||
try {
|
||||
// YTD actual revenue from orders
|
||||
@@ -1055,11 +1048,7 @@ router.get('/year-revenue-estimate', async (req, res) => {
|
||||
const { rows: [horizonRow] } = await executeQuery(
|
||||
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
|
||||
);
|
||||
const forecastHorizonISO = horizonRow?.max_date
|
||||
? (horizonRow.max_date instanceof Date
|
||||
? horizonRow.max_date.toISOString().split('T')[0]
|
||||
: horizonRow.max_date)
|
||||
: todayISO;
|
||||
const forecastHorizonISO = horizonRow?.max_date ? formatDateCol(horizonRow.max_date) : todayISO;
|
||||
|
||||
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
|
||||
|
||||
@@ -1111,13 +1100,9 @@ router.get('/year-revenue-estimate', async (req, res) => {
|
||||
// GET /dashboard/sales/metrics
|
||||
// Returns sales metrics for specified period
|
||||
router.get('/sales/metrics', async (req, res) => {
|
||||
// Default to last 30 days if no date range provided
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const startDate = req.query.startDate || thirtyDaysAgo.toISOString();
|
||||
const endDate = req.query.endDate || today.toISOString();
|
||||
// Default: trailing 30 business days ending now (Chicago day start).
|
||||
const startDate = req.query.startDate || businessNow().minus({ days: 30 }).startOf('day').toISO();
|
||||
const endDate = req.query.endDate || new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Get daily orders and totals for the specified period
|
||||
@@ -1187,13 +1172,13 @@ router.get('/sales/metrics', async (req, res) => {
|
||||
totalCogs: parseFloat(metrics?.total_cogs) || 0,
|
||||
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
|
||||
dailySales: dailyRows.map(day => ({
|
||||
date: day.sale_date,
|
||||
date: formatDateCol(day.sale_date),
|
||||
units: parseInt(day.total_units) || 0,
|
||||
revenue: parseFloat(day.total_revenue) || 0,
|
||||
cogs: parseFloat(day.total_cogs) || 0
|
||||
})),
|
||||
dailySalesByPhase: dailyPhaseRows.map(d => ({
|
||||
date: d.sale_date,
|
||||
date: formatDateCol(d.sale_date),
|
||||
preorder: parseFloat(d.preorder) || 0,
|
||||
launch: parseFloat(d.launch) || 0,
|
||||
decay: parseFloat(d.decay) || 0,
|
||||
|
||||
@@ -676,7 +676,10 @@ async function setupSshTunnel() {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: process.env.PROD_DB_PORT || 3306,
|
||||
timezone: 'Z'
|
||||
// DATETIME columns store Central wall-clock literals — return them as
|
||||
// strings; parse with shared/business-time parseMySql() if instants are
|
||||
// ever needed (see docs/TIME.md).
|
||||
dateStrings: true
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Get all orders with pagination, filtering, and sorting
|
||||
@@ -10,8 +11,9 @@ router.get('/', async (req, res) => {
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
const status = req.query.status || 'all';
|
||||
const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null;
|
||||
const toDate = req.query.toDate ? new Date(req.query.toDate) : null;
|
||||
// Date filters are BUSINESS dates ('YYYY-MM-DD', Chicago calendar).
|
||||
const fromDate = req.query.fromDate ? businessDateOf(req.query.fromDate) : null;
|
||||
const toDate = req.query.toDate ? businessDateOf(req.query.toDate) : null;
|
||||
const minAmount = parseFloat(req.query.minAmount) || 0;
|
||||
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
|
||||
const sortColumn = req.query.sortColumn || 'date';
|
||||
@@ -35,14 +37,14 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`);
|
||||
params.push(fromDate.toISOString());
|
||||
conditions.push(`DATE(o1.date) >= $${paramCounter}::date`);
|
||||
params.push(fromDate);
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`);
|
||||
params.push(toDate.toISOString());
|
||||
conditions.push(`DATE(o1.date) <= $${paramCounter}::date`);
|
||||
params.push(toDate);
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { formatDateCol } from '../../shared/business-time/index.js';
|
||||
import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -1003,7 +1004,7 @@ router.get('/:id/forecast', async (req, res) => {
|
||||
phase,
|
||||
method,
|
||||
forecast: rows.map(r => ({
|
||||
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
|
||||
date: formatDateCol(r.date),
|
||||
units: parseFloat(r.units) || 0,
|
||||
revenue: parseFloat(r.revenue) || 0,
|
||||
confidenceLower: parseFloat(r.confidence_lower) || 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessNow } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Status code constants
|
||||
@@ -716,7 +717,7 @@ router.get('/category-analysis', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Allow an optional "since" parameter or default to 1 year ago
|
||||
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
|
||||
|
||||
const { rows: analysis } = await pool.query(`
|
||||
WITH receiving_costs AS (
|
||||
@@ -769,7 +770,7 @@ router.get('/vendor-analysis', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Allow an optional "since" parameter or default to 1 year ago
|
||||
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
|
||||
|
||||
const { rows: metrics } = await pool.query(`
|
||||
WITH receiving_data AS (
|
||||
|
||||
@@ -5,7 +5,13 @@ const { Pool } = pg;
|
||||
let pool;
|
||||
|
||||
export function initPool(config) {
|
||||
pool = new Pool(config);
|
||||
pool = new Pool({
|
||||
// Pin the session timezone to business time (matches the
|
||||
// `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` default)
|
||||
// so a DB restore/migration can't silently revert date semantics.
|
||||
options: '-c TimeZone=America/Chicago',
|
||||
...config,
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ async function setupSshTunnel() {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: Number(process.env.PROD_DB_PORT) || 3306,
|
||||
timezone: 'Z',
|
||||
// DATETIME columns store Central wall-clock literals — return them as
|
||||
// strings; parse with shared/business-time parseMySql() if instants are
|
||||
// ever needed (see docs/TIME.md).
|
||||
dateStrings: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react';
|
||||
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
|
||||
|
||||
interface FlowPoint {
|
||||
date: string;
|
||||
@@ -71,21 +72,7 @@ export function InventoryFlow() {
|
||||
Daily cost of goods received vs cost of goods sold
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([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>
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90]} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'recharts';
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
|
||||
|
||||
interface TrendPoint {
|
||||
date: string;
|
||||
@@ -52,21 +53,7 @@ export function InventoryTrends() {
|
||||
Units sold per day with stockout product count overlay
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([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>
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90, 365]} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import config from '../../config';
|
||||
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
|
||||
import { formatCurrency } from '@/utils/formatCurrency';
|
||||
import { HorizonTabs } from '@/components/dashboard/shared/HorizonTabs';
|
||||
|
||||
interface ValuePoint {
|
||||
date: string;
|
||||
@@ -69,21 +70,7 @@ export function InventoryValueTrend() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{([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>
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[30, 90, 365]} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OFFICE_TZ = 'America/New_York'; // office wall-clock (see docs/TIME.md)
|
||||
const officeHour = (date) =>
|
||||
Number(date.toLocaleString('en-US', { hour: 'numeric', hour12: false, timeZone: OFFICE_TZ })) % 24;
|
||||
|
||||
const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
const [datetime, setDatetime] = useState(new Date());
|
||||
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
|
||||
@@ -93,11 +97,10 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
}, [prevTime]);
|
||||
|
||||
function getTimeComponents(date) {
|
||||
let hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
const hour24 = officeHour(date);
|
||||
const minutes = Number(date.toLocaleString('en-US', { minute: 'numeric', timeZone: OFFICE_TZ }));
|
||||
const ampm = hour24 >= 12 ? 'PM' : 'AM';
|
||||
const hours = hour24 % 12 || 12;
|
||||
return {
|
||||
hours: hours.toString(),
|
||||
minutes: minutes.toString().padStart(2, '0'),
|
||||
@@ -107,9 +110,9 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
|
||||
const formatDate = (date) => {
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en-US', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en-US', { month: 'long' }),
|
||||
day: date.getDate()
|
||||
weekday: date.toLocaleDateString('en-US', { weekday: 'long', timeZone: OFFICE_TZ }),
|
||||
month: date.toLocaleDateString('en-US', { month: 'long', timeZone: OFFICE_TZ }),
|
||||
day: Number(date.toLocaleDateString('en-US', { day: 'numeric', timeZone: OFFICE_TZ }))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -117,7 +120,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
if (!weatherCode) return <CircleAlert className="w-12 h-12 text-red-500" />;
|
||||
const code = parseInt(weatherCode, 10);
|
||||
const iconProps = small ? "w-8 h-8" : "w-12 h-12";
|
||||
const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6;
|
||||
const isNight = officeHour(currentTime) >= 18 || officeHour(currentTime) < 6;
|
||||
|
||||
switch (true) {
|
||||
case code >= 200 && code < 300:
|
||||
@@ -137,7 +140,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
case code === 781:
|
||||
return <Tornado className={cn(iconProps, "text-gray-300")} />;
|
||||
case code === 800:
|
||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
||||
return officeHour(currentTime) >= 6 && officeHour(currentTime) < 18 ? (
|
||||
<Sun className={cn(iconProps, "text-yellow-300")} />
|
||||
) : (
|
||||
<Moon className={cn(iconProps, "text-gray-300")} />
|
||||
@@ -230,7 +233,8 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
hour12: true,
|
||||
timeZone: OFFICE_TZ
|
||||
});
|
||||
};
|
||||
|
||||
@@ -303,7 +307,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{forecast.map((day, index) => {
|
||||
const forecastTime = new Date(day.dt * 1000);
|
||||
const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6;
|
||||
const isNight = officeHour(forecastTime) >= 18 || officeHour(forecastTime) < 6;
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -392,7 +396,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
|
||||
<Card className={cn(
|
||||
getWeatherBackground(
|
||||
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"
|
||||
)}>
|
||||
|
||||
@@ -43,6 +43,7 @@ import PeriodSelectionPopover, {
|
||||
} from "@/components/dashboard/PeriodSelectionPopover";
|
||||
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import { toDateOnly } from "@/utils/businessTime";
|
||||
import {
|
||||
DashboardSectionHeader,
|
||||
DashboardStatCard,
|
||||
@@ -759,8 +760,11 @@ const FinancialOverview = () => {
|
||||
return;
|
||||
}
|
||||
params.timeRange = "custom";
|
||||
params.startDate = requestRange.start.toISOString();
|
||||
params.endDate = requestRange.end.toISOString();
|
||||
// Date-only strings = business dates; the server resolves them to
|
||||
// business-day bounds (1am ET / Chicago midnight). Never send
|
||||
// browser-local-midnight instants.
|
||||
params.startDate = toDateOnly(requestRange.start);
|
||||
params.endDate = toDateOnly(requestRange.end);
|
||||
}
|
||||
|
||||
const response = (await acotService.getFinancials(params)) as FinancialResponse;
|
||||
|
||||
@@ -8,15 +8,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { Mail, MessageSquare, BookOpen } from "lucide-react";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
@@ -358,18 +351,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
</div>
|
||||
}
|
||||
timeSelector={
|
||||
<Select 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>
|
||||
<BusinessRangeSelect value={selectedTimeRange} onValueChange={setSelectedTimeRange} />
|
||||
}
|
||||
/>
|
||||
<CardContent className="pl-4 mb-4">
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
METRIC_COLORS,
|
||||
} from "@/components/dashboard/shared";
|
||||
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toDateOnly } from "@/utils/businessTime";
|
||||
|
||||
type ComparisonValue = {
|
||||
absolute: number | null;
|
||||
@@ -335,8 +336,11 @@ const OperationsMetrics = () => {
|
||||
return;
|
||||
}
|
||||
params.timeRange = "custom";
|
||||
params.startDate = requestRange.start.toISOString();
|
||||
params.endDate = requestRange.end.toISOString();
|
||||
// Date-only strings = business dates; the server resolves them to
|
||||
// business-day bounds (1am ET / Chicago midnight). Never send
|
||||
// browser-local-midnight instants.
|
||||
params.startDate = toDateOnly(requestRange.start);
|
||||
params.endDate = toDateOnly(requestRange.end);
|
||||
}
|
||||
|
||||
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
|
||||
|
||||
@@ -12,16 +12,9 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -235,21 +228,10 @@ const ProductGrid = ({
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Select
|
||||
<BusinessRangeSelect
|
||||
value={selectedTimeRange}
|
||||
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>
|
||||
{isSearchVisible && !error && (
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import {
|
||||
@@ -21,7 +15,6 @@ import {
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
@@ -463,21 +456,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
|
||||
// Time selector for DashboardSectionHeader
|
||||
const timeSelector = (
|
||||
<Select
|
||||
<BusinessRangeSelect
|
||||
value={selectedTimeRange}
|
||||
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
|
||||
|
||||
@@ -7,13 +7,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,7 +43,6 @@ import {
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { DateTime } from "luxon";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardStatCard,
|
||||
@@ -1475,18 +1468,7 @@ const StatCards = ({
|
||||
Last updated: {lastUpdate.toFormat("hh:mm a")}
|
||||
</span>
|
||||
)}
|
||||
<Select 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>
|
||||
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,18 +1529,7 @@ const StatCards = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Select 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>
|
||||
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
||||
</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">
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -1), 1), to: new Date() })}>
|
||||
Last Month
|
||||
Trailing Month
|
||||
</Button>
|
||||
<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 size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -6), 1), to: new Date() })}>
|
||||
Last 6 Months
|
||||
Trailing 6 Months
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ from: addDays(addMonths(new Date(), -12), 1), to: new Date() })}>
|
||||
Last Year
|
||||
Trailing 12 Months
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { ClipboardList, Package, DollarSign, ShoppingCart } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { HorizonTabs } from "@/components/dashboard/shared/HorizonTabs"
|
||||
import { addDays, format } from "date-fns"
|
||||
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases"
|
||||
|
||||
@@ -74,13 +74,7 @@ export function SalesMetrics() {
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">7D</TabsTrigger>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
<TabsTrigger value="90">90D</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { acotService } from "@/services/dashboard/acotService";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
import { DateTime } from "luxon";
|
||||
import { businessTodayStr } from "@/utils/businessTime";
|
||||
|
||||
type BlackFridayRange = {
|
||||
start: DateTime;
|
||||
@@ -513,8 +514,12 @@ function LoadingBlock() {
|
||||
}
|
||||
|
||||
export function BlackFridayDashboard() {
|
||||
const currentYear = new Date().getUTCFullYear();
|
||||
const currentMonth = new Date().getUTCMonth() + 1; // getUTCMonth() returns 0-11, so add 1
|
||||
// Anchor on the BUSINESS date (Chicago calendar), not UTC.
|
||||
const [businessYear, businessMonth] = businessTodayStr()
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const currentYear = businessYear;
|
||||
const currentMonth = businessMonth;
|
||||
// Show previous year until November of current year
|
||||
const displayYear = currentMonth < 11 ? currentYear - 1 : currentYear;
|
||||
const availableYears = useMemo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { subMonths, startOfDay, endOfDay } from "date-fns";
|
||||
import { subMonths } from "date-fns";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CogsCalculationMode,
|
||||
} from "@/types/discount-simulator";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { toDateOnly } from "@/utils/businessTime";
|
||||
|
||||
const DEFAULT_POINT_VALUE = 0.005;
|
||||
const DEFAULT_REDEMPTION_RATE = 0.9;
|
||||
@@ -78,8 +79,9 @@ export function DiscountSimulator() {
|
||||
const promoDateBounds = useMemo(() => {
|
||||
const { from, to } = ensureDateRange(dateRange);
|
||||
return {
|
||||
startDate: startOfDay(from).toISOString(),
|
||||
endDate: endOfDay(to).toISOString(),
|
||||
// Date-only strings = business dates; the server resolves bounds.
|
||||
startDate: toDateOnly(from),
|
||||
endDate: toDateOnly(to),
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
@@ -138,8 +140,8 @@ export function DiscountSimulator() {
|
||||
|
||||
return {
|
||||
dateRange: {
|
||||
start: startOfDay(from).toISOString(),
|
||||
end: endOfDay(to).toISOString(),
|
||||
start: toDateOnly(from),
|
||||
end: toDateOnly(to),
|
||||
},
|
||||
filters: {
|
||||
shipCountry: 'US',
|
||||
@@ -271,8 +273,13 @@ export function DiscountSimulator() {
|
||||
|
||||
if (parsed.dateRange) {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
const fromValue = parsed.dateRange.start ? new Date(parsed.dateRange.start) : undefined;
|
||||
const toValue = parsed.dateRange.end ? new Date(parsed.dateRange.end) : undefined;
|
||||
// Saved values may be date-only ('YYYY-MM-DD') or legacy ISO instants.
|
||||
// Parse date-only as LOCAL (bare new Date() would treat it as UTC and
|
||||
// show the previous day in US zones).
|
||||
const parseSaved = (value: string) =>
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00`) : new Date(value);
|
||||
const fromValue = parsed.dateRange.start ? parseSaved(parsed.dateRange.start) : undefined;
|
||||
const toValue = parsed.dateRange.end ? parseSaved(parsed.dateRange.end) : undefined;
|
||||
setDateRange({
|
||||
from: fromValue ?? defaultRange.from,
|
||||
to: toValue ?? defaultRange.to,
|
||||
@@ -348,8 +355,8 @@ export function DiscountSimulator() {
|
||||
const { from, to } = ensureDateRange(dateRange);
|
||||
return JSON.stringify({
|
||||
dateRange: {
|
||||
start: startOfDay(from).toISOString(),
|
||||
end: endOfDay(to).toISOString(),
|
||||
start: toDateOnly(from),
|
||||
end: toDateOnly(to),
|
||||
},
|
||||
selectedPromoId: selectedPromoId ?? null,
|
||||
productPromo,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { X, Layers, FolderTree, TrendingUp, Palette } from "lucide-react";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toDateOnly } from "@/utils/businessTime";
|
||||
|
||||
type GroupMode = "line" | "category" | "designer";
|
||||
|
||||
@@ -126,8 +127,9 @@ export default function Forecasting() {
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
brand: selectedBrand,
|
||||
startDate: dateRange.from?.toISOString() || "",
|
||||
endDate: dateRange.to?.toISOString() || "",
|
||||
// Date-only strings = business dates (see docs/TIME.md)
|
||||
startDate: dateRange.from ? toDateOnly(dateRange.from) : "",
|
||||
endDate: dateRange.to ? toDateOnly(dateRange.to) : "",
|
||||
});
|
||||
const response = await apiFetch(`/api/analytics/forecast-v2?${params}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch forecast data");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user