diff --git a/PRPs/PRP-22-visualize-demand-planner.md b/PRPs/PRP-22-visualize-demand-planner.md new file mode 100644 index 00000000..a42fe22a --- /dev/null +++ b/PRPs/PRP-22-visualize-demand-planner.md @@ -0,0 +1,1030 @@ +name: "PRP-22 — Visualize: Demand Planner page + interactive Forecast/Backtest pages" +description: | + Extend the **Visualize** menu of the ForecastLabAI dashboard from two flat, + read-only job viewers into a business-facing demand surface — the direct + successor to PRP-20/PRP-21 (Explorer interactivity), applied to the + `analytics` slice and `frontend/src/pages/visualize/`: + + 1. **New backend endpoint** — `GET /analytics/inventory-status` exposes the + latest `inventory_snapshot_daily` row per `(store, product)` grain + (`on_hand_qty`, `on_order_qty`, `is_stockout`). The inventory fact table + has no HTTP surface today; the Demand Planner needs it to compute an + inventory requirement. Additive — no migration, no new slice, no + `app/main.py` change (the `analytics` router is already wired). + + 2. **New page** — `Visualize → Demand Planner` (`/visualize/demand`). It reads + every completed `predict` job, joins each to its product (SKU/name) and to + the latest inventory snapshot, and shows a multi-SKU table: + **tomorrow's sales** · **next week** · **next month** · **inventory + requirement (`készletigény`)**. A row click opens a single-SKU drill-in + with the daily demand curve and the reorder breakdown. + + 3. **Interactivity** for the existing **Forecast** and **Backtest Results** + pages — in-page job submission, a prediction-interval band toggle on the + forecast chart, CSV export, and run/job cross-links. + + Hungarian terms in the source request map to: *holnapi eladás* → tomorrow's + sales, *következő hét* → next week, *következő hónap* → next month, + *SKU demand* → per-SKU demand, *készletigény* → inventory requirement. **All + UI copy is English** — every existing page in this repo is English. + +> **PRP numbering:** `PRP-16` is reserved (Phase-2 LightGBM). `PRP-17`–`PRP-21` +> are used. This is `PRP-22`. Source plan: +> `.agents/plans/visualize-demand-planner-and-interactivity.md`. + +## Purpose +Close the "Visualize pages are terminal viewers" gap. Today `forecast.tsx` loads +one `predict` job and draws a line; `backtest.tsx` loads one `backtest` job and +shows metrics. Neither rolls a forecast up into business numbers, surfaces stock +context, exports, links to the originating run, or lets the user launch a job. +There is **no view that answers "how much will this SKU sell tomorrow / next week +/ next month, and do I have enough stock?"** — and `inventory_snapshot_daily`, +though populated by the seeder, is invisible to the API. PRP-20/PRP-21 made the +Explorer pages interactive; this PRP does the equivalent for the Visualize area +and adds the one missing read endpoint that a demand view requires. + +## Core Principles +1. **Context is King** — every endpoint shape, hook name, schema field, service + method, and pattern below is linked to a real source file + verified line + numbers. +2. **Reuse existing patterns** — the endpoint mirrors `analytics`'s + `get_timeseries`; the hook mirrors `use-timeseries.ts`; the page skeleton + mirrors `forecast.tsx`; the drill-in mirrors `run-detail.tsx`; CSV reuses + `csv-export.ts`. +3. **Additive only** — no Alembic migration (the inventory table already + exists; the endpoint is read-only), no new slice, no new `.env` var, no + `app/main.py` change (the `analytics` router is already wired). +4. **Strict gates honored** — `.py` files in the `analytics` slice change, so + the repo-wide `ruff` / `mypy --strict` / `pyright --strict` / `pytest` CI + jobs genuinely apply; the new endpoint ships with slice tests. +5. **UI through skills** — pages built via `frontend-design` + `shadcn-ui`, + dogfooded via `webapp-testing` / `agent-browser` per + `.claude/rules/ui-design.md`. A green `tsc` is NOT proof the UI works. + +--- + +## Goal + +**Backend (additive, no migration):** +- `GET /analytics/inventory-status` — optional `store_id` / `product_id` filters; + returns the latest snapshot per `(store, product)` grain via Postgres + `DISTINCT ON`; `200` + empty list on an empty table (never `404`). + +**Frontend:** +- New route `/visualize/demand` + `Visualize → Demand Planner` nav entry. +- `demand.tsx` — a multi-SKU demand table built client-side from completed + `predict` jobs, with a lead-time selector and a single-SKU drill-in panel. +- One new hook (`useInventoryStatus`), one new pure module (`demand-utils.ts` + + tests), three new TS types (`InventoryStatusItem`, `InventoryStatusResponse`, + `DemandRow`). +- `TimeSeriesChart` extended with an optional prediction-interval band + (backward-compatible — new props default off). +- `forecast.tsx` / `backtest.tsx` upgraded: in-page job submission, CSV export, + interval-band toggle (forecast), run/job cross-links. + +## Why +- **Portfolio identity.** `.claude/rules/product-vision.md` principle 1 — + "portfolio-grade, end-to-end … every phase ships working code". The system + trains, backtests and registers models, but a reviewer cannot see what a + forecast *means for the business* — only a raw daily array. +- **Demand-planner workflow.** "What do I sell tomorrow / this week / this + month, and do I need to reorder?" is the core retail question; today it is + unanswerable in-product. +- **Consistency.** Explorer pages have detail views, sorting, export and + cross-links (PRP-20/21). The Visualize pages being read-only single-job + viewers is a visible inconsistency. +- **Unlocks the inventory fact table.** `inventory_snapshot_daily` is seeded but + has no API surface — this PRP gives it one, reusably. + +## What +One new read-only `analytics` endpoint (+ tests), one new page, two page +refactors, one shared-chart change. No migration, no new slice, no `.env` var, +no `app/main.py` change. + +### Success Criteria +- [ ] `GET /analytics/inventory-status` returns the latest snapshot per + `(store, product)`; honours `store_id` / `product_id`; returns `200` with + `items: []`, `total_items: 0` on an empty table. +- [ ] `Visualize → Demand Planner` lists every completed `predict` job as a SKU + row with tomorrow / next-week / next-month demand + inventory requirement; + a lead-time selector (7/14/30 d) recomputes the requirement live. +- [ ] A demand row click opens a drill-in with the SKU's daily demand curve, the + reorder breakdown, and cross-links to the source job + (when present) the + model run. +- [ ] The Forecast page can launch a `predict` job in-page (pick a completed + `train` job + horizon); the interval-band toggle and CSV export work. +- [ ] The Backtest page can launch a `backtest` job in-page; CSV export of fold + metrics works; the loaded job links to its job detail page. +- [ ] `uv run ruff check . && uv run ruff format --check . && uv run mypy app/ + && uv run pyright app/` clean; `uv run pytest -v -m "not integration"` and + the `analytics` integration tests green. +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` clean. +- [ ] No Alembic migration; no new slice; no `app/main.py` change; no `.env` var. +- [ ] The Demand Planner + both upgraded pages dogfooded in a real browser + (screenshots captured). + +--- + +## All Needed Context + +### Documentation & References +```yaml +# ---- External docs ---- +- url: https://docs.sqlalchemy.org/en/20/core/selectable.html#sqlalchemy.sql.expression.Select.distinct + why: select(Model).distinct(col_a, col_b) renders Postgres DISTINCT ON. + critical: The DISTINCT ON columns MUST be the leftmost ORDER BY columns — + order_by(store_id, product_id, date.desc()) then .distinct(store_id, + product_id) gives the latest row per grain. Wrong order → SQL error. + +- url: https://www.postgresql.org/docs/16/sql-select.html#SQL-DISTINCT + why: DISTINCT ON semantics — keeps the first row of each distinct group as + defined by ORDER BY. This is how "latest snapshot per grain" is expressed. + +- url: https://tanstack.com/query/latest/docs/framework/react/guides/queries + why: useQuery shape for the new useInventoryStatus hook. The repo hooks + (use-timeseries.ts, use-jobs.ts) follow this exactly — copy that shape. + +- url: https://tanstack.com/query/latest/docs/framework/react/guides/mutations + why: useCreateJob (use-jobs.ts:55-64) is a useMutation; the in-page "Run new…" + buttons call .mutateAsync then point the page at the returned job_id. + +- url: https://recharts.org/en-US/api/Area + why: the prediction-interval band. An whose dataKey returns a tuple + [lower, upper] renders a range band. Forecast line + band needs a + (Area + Line together), not a plain . + critical: recharts is pinned at 2.15.4 (frontend/package.json) — ComposedChart + and range-Area both exist in 2.15.x. If the range-dataKey form is uncertain, + confirm via the contex7 MCP (resolve-library-id "recharts" → query-docs + "ComposedChart Area range band"). + +- url: https://reactrouter.com/en/main/hooks/use-params + why: react-router-dom is v7.13.0 — useParams() for an optional drill-in id if + the drill-in is ever promoted to its own route (not required for the MVP; + the drill-in is an in-page panel). + +# ---- THE sibling PRPs to mirror ---- +- file: PRPs/PRP-21-explorer-runs-jobs-interactivity.md + why: the immediate precedent. Read its "Known Gotchas", "list of tasks", + "Validation Loop" and "Anti-Patterns" — this PRP applies the same shape to + the analytics slice + visualize pages. PRP-21 added csv-export.ts usage, + cross-links, and detail-page patterns — all REUSED here, not rebuilt. +- file: PRPs/PRP-20-explorer-interactivity.md + why: PRP-20 introduced revenue-bar-chart.tsx, json-block.tsx, csv-export.ts, + use-timeseries.ts, use-lifecycle-curve.ts and the store/product detail + pages — the exact patterns this PRP composes. + +# ---- Backend: the analytics slice (gets the new endpoint) ---- +- file: app/features/analytics/routes.py + why: get_timeseries (lines 259-345) is the EXACT template — APIRouter, + Query(...) params, rich docstring, `service = AnalyticsService()` call. + The router is `APIRouter(prefix="/analytics", tags=["analytics"])` (line 26). + critical: inventory-status has NO date params, so do NOT call + validate_date_range (lines 34-59) — that helper is for date-range endpoints. + +- file: app/features/analytics/service.py + why: AnalyticsService.compute_kpis (lines 43-90) — the SQLAlchemy 2.0 async + `select(...).where(...)`, `await db.execute(stmt)`, settings + logger style + to mirror for compute_inventory_status. Imports at lines 10-27. + +- file: app/features/analytics/schemas.py + why: TimeSeriesPoint / TimeSeriesResponse (lines 199-255) — the response-schema + shape to mirror: `model_config = ConfigDict(from_attributes=True)`, rich + `Field(..., description=...)`, echoed filter fields, a `total_*` count. + +- file: app/features/data_platform/models.py + why: InventorySnapshotDaily (lines 345-381) — the table queried. Columns: + `id`, `date` (FK calendar.date), `store_id` (FK store.id), `product_id` + (FK product.id), `on_hand_qty:int`, `on_order_qty:int default 0`, + `is_stockout:bool default False`. Unique grain (date, store_id, product_id). + +- file: app/features/analytics/tests/test_routes_integration.py + why: integration-test layout — `@pytest.mark.integration`, `@pytest.mark.asyncio`, + class-grouped, uses the `client` fixture + seeded fixtures. Add a + `TestAnalyticsInventoryStatusIntegration` class here (lines 21-58 style). + +- file: app/features/analytics/tests/conftest.py + why: fixtures — `db_session` (lines 99-130), `client` (133-148), `sample_store` + (151-165), `sample_product` (168-183), `sample_calendar_120` (186-206). + critical: the db_session cleanup (lines 119-128) deletes ALL SalesDaily, then + `Product`/`Store` rows with a `TEST-` code/sku, then Calendar in + 2024-01-01..2024-04-29 — it does NOT delete InventorySnapshotDaily. A new + `sample_inventory` fixture MUST add `delete(InventorySnapshotDaily)` to that + cleanup BEFORE the Store/Product/Calendar deletes (FK order), or rows leak / + FK-block. See Gotchas. + +- file: app/features/analytics/tests/test_schemas.py + why: schema unit-test pattern for the new InventoryStatus* schemas. + +- file: app/main.py + why: the analytics router is ALREADY wired (`analytics_router` import line 17, + `app.include_router(analytics_router)` line 133). NO main.py change needed. + +# ---- Backend: job-result + job-param contracts (read-only — do NOT change) ---- +- file: app/features/jobs/service.py + why: + - _execute_predict (lines 493-560): a completed `predict` job stores + `result = {store_id, product_id, model_type, horizon, + forecasts:[{date:isostr, forecast:float, lower_bound?, upper_bound?}]}`. + The Demand Planner + Forecast page consume exactly this. + - _execute_train (lines ~400-491): a completed `train` job stores + `result = {run_id, model_type, model_path, config_hash, n_observations, + train_start_date, train_end_date, store_id, product_id, duration_ms}`. + `run_id` is the ARTIFACT key (model_{run_id}.joblib), NOT a registry run. + - _execute_predict params contract: `{run_id:str, horizon:int}` — `run_id` + MUST be a train job's `result.run_id`. + - _execute_backtest (lines 569-655) params contract: `{model_type, store_id, + product_id, start_date, end_date, n_splits?(=5), test_size?(=14), + gap?(=0), season_length?(=7), window_size?(=7)}`; dates accepted as ISO + strings. Result shaped by _shape_backtest_result (lines 71-136). + critical: forecasting/service.py does NOT touch the registry — training does + not create a `model_run` row. The in-page "Run new forecast" MUST pick a + completed `train` job and submit its `result.run_id`; a registry run_id from + `useRuns` would NOT resolve to a model artifact. See Gotchas. + +- file: app/features/forecasting/schemas.py + why: PredictRequest / ForecastPoint / PredictResponse (lines 227-289) — + `horizon` bounded 1..90; ForecastPoint has optional `lower_bound`/ + `upper_bound`. + +# ---- Frontend: pages being upgraded / mirrored ---- +- file: frontend/src/pages/visualize/forecast.tsx + why: the page upgraded in Tasks 11-12. Current: JobPicker (jobType="predict") + + useJob + TimeSeriesChart. The forecast array is read defensively as + `job?.result?.forecasts as Array<{date,forecast}> | undefined` (line 17). +- file: frontend/src/pages/visualize/backtest.tsx + why: the page upgraded in Task 13. Current: JobPicker (jobType="backtest") + + BacktestResult interface (11-30) + MetricsSummary + BacktestFoldsChart. +- file: frontend/src/pages/explorer/run-detail.tsx + why: THE detail/drill-in template — header with back-Link, `Field` helper + (lines 27-34), Card sections, cross-`Link` to `/explorer/stores|products`, + LoadingState/ErrorDisplay. Mirror for the Demand Planner drill-in panel. +- file: frontend/src/pages/explorer/runs.tsx + why: a working interactive table page (for the demand table layout reference). + +# ---- Frontend: components (REUSED, not built) ---- +- file: frontend/src/components/charts/time-series-chart.tsx + why: the chart extended in Task 10. Props actualKey/predictedKey/showActual/ + showPredicted/height. Note the oklch `var(--color-*)` comment (lines 42-43) + — wrapping in hsl() renders black. +- file: frontend/src/components/common/job-picker.tsx + why: JobPicker — a `useJobs`-backed Select + manual-id entry. Its prop type is + `jobType: Extract` (line 18) — WIDEN it to + include `'train'` for the in-page-forecast train-job picker. +- file: frontend/src/components/ui/table.tsx + why: the plain shadcn Table (Table/TableHeader/TableBody/TableRow/TableCell/ + TableHead). Use THIS for the demand table — see Gotchas (DataTable is + server-paginated; the demand rows are client-derived). +- file: frontend/src/components/ui/select.tsx + why: shadcn Select for the lead-time selector + the in-page-run model/store/ + product pickers. +- file: frontend/src/components/ui/checkbox.tsx + why: the interval-band toggle on the Forecast page. +- file: frontend/src/components/common/date-range-picker.tsx + why: DateRangePicker (value: DateRange from react-day-picker, onChange) — for + the in-page backtest date window. +- file: frontend/src/components/common/error-display.tsx + why: ErrorDisplay + EmptyState — used for the three page states. +- file: frontend/src/components/common/loading-state.tsx + why: LoadingState. +- file: frontend/src/components/common/status-badge.tsx + why: StatusBadge for the stockout / partial-horizon badges. +- file: frontend/src/components/charts/kpi-card.tsx + why: KPICard (title, value, icon?) — OPTIONAL, for the tomorrow/week/month + headline tiles in the drill-in. + +# ---- Frontend: hooks / libs ---- +- file: frontend/src/hooks/use-timeseries.ts + why: THE hook shape to copy verbatim for use-inventory.ts — useQuery, + `queryKey` array, `api('/path',{params})`, keepPreviousData, enabled. +- file: frontend/src/hooks/use-jobs.ts + why: useJob (polls 2s while pending/running), useJobs, useCreateJob (mutation). +- file: frontend/src/hooks/use-products.ts + why: useProducts({page,pageSize,...}) → ProductListResponse{products:Product[]}. +- file: frontend/src/hooks/use-stores.ts + why: useStores — for the in-page backtest store picker (mirror use-products). +- file: frontend/src/lib/api.ts + why: `api(endpoint,{params,method,body})` (drops undefined/null/'' params), + formatNumber/formatCurrency/formatPercent, getErrorMessage, ApiError. +- file: frontend/src/lib/csv-export.ts + why: toCsv(rows, columns) + downloadCsv(filename, csv) + CsvColumn + (`key: keyof T & string`, `header`) — reused for both export buttons. +- file: frontend/src/lib/csv-export.test.ts + why: vitest pattern for the new demand-utils.test.ts. +- file: frontend/src/lib/constants.ts + why: ROUTES.VISUALIZE (lines 21-24) + the `Visualize` NAV_ITEMS submenu + (lines 45-51) — add DEMAND here. +- file: frontend/src/App.tsx + why: lazy imports (23-24) + the ROUTES.VISUALIZE.FORECAST block + (137-144) — add the demand page identically. +- file: frontend/src/types/api.ts + why: existing Job (183-196), JobCreate (202-205), JobType (180), Product + (25-35), ProductListResponse (37-39). Add InventoryStatusItem, + InventoryStatusResponse, DemandRow. + +# ---- Rules ---- +- file: .claude/rules/ui-design.md + why: UI built via frontend-design + shadcn-ui; dogfooded via webapp-testing / + agent-browser. A green tsc is NOT proof the UI works. +- file: .claude/rules/security-patterns.md + why: GET endpoint, Pydantic-validated Query params, SQLAlchemy parameter + binding (no raw SQL). No request body ⇒ the strict-mode date rule does not + apply. +- file: .claude/rules/test-requirements.md + why: new endpoint → route test (2xx + 1 error/edge path); new pure utils → + unit tests; new stateful component → a vitest test; integration tests run + against real Postgres, never mocked. +- file: .claude/rules/commit-format.md & branch-naming.md + why: `type(scope): description (#issue)`; scopes `analytics`/`ui`/`docs`; + branch `feat/ui-visualize-demand-planner` off `dev`; open the issue FIRST. +``` + +### Current Codebase tree (relevant) +```bash +app/features/analytics/ +├── routes.py # MOD — +GET /inventory-status +├── service.py # MOD — +compute_inventory_status +├── schemas.py # MOD — +InventoryStatusItem/Response +└── tests/ + ├── conftest.py # MOD — +sample_inventory, +inventory cleanup + ├── test_routes_integration.py # MOD — +TestAnalyticsInventoryStatusIntegration + └── test_schemas.py # MOD — +inventory schema unit tests + +frontend/src/ +├── App.tsx # MOD — +1 lazy route (demand) +├── lib/ +│ ├── constants.ts # MOD — +ROUTES.VISUALIZE.DEMAND + nav entry +│ ├── demand-utils.ts # NEW — pure rollup/inventory math +│ └── demand-utils.test.ts # NEW — vitest unit tests +├── types/api.ts # MOD — +InventoryStatusItem/Response, DemandRow +├── hooks/use-inventory.ts # NEW — useInventoryStatus +├── components/ +│ ├── charts/time-series-chart.tsx # MOD — +optional interval band +│ └── common/job-picker.tsx # MOD — widen jobType to include 'train' +└── pages/visualize/ + ├── demand.tsx # NEW — Demand Planner page + ├── forecast.tsx # MOD — in-page run, interval toggle, CSV, links + └── backtest.tsx # MOD — in-page run, CSV, links +``` + +### Desired Codebase tree (files added / changed) +```bash +NEW frontend/src/lib/demand-utils.ts # sumWindow/rollups/inventoryRequirement/joinDemandRows +NEW frontend/src/lib/demand-utils.test.ts # vitest unit tests +NEW frontend/src/hooks/use-inventory.ts # useInventoryStatus query hook +NEW frontend/src/pages/visualize/demand.tsx # /visualize/demand page +MOD app/features/analytics/schemas.py # +InventoryStatusItem/Response +MOD app/features/analytics/service.py # +compute_inventory_status +MOD app/features/analytics/routes.py # +GET /inventory-status +MOD app/features/analytics/tests/conftest.py # +sample_inventory + cleanup +MOD app/features/analytics/tests/test_routes_integration.py # +inventory tests +MOD app/features/analytics/tests/test_schemas.py # +schema tests +MOD frontend/src/types/api.ts # +3 types +MOD frontend/src/lib/constants.ts # +route + nav entry +MOD frontend/src/App.tsx # +1 lazy route +MOD frontend/src/components/charts/time-series-chart.tsx # +interval band +MOD frontend/src/components/common/job-picker.tsx # widen jobType +MOD frontend/src/pages/visualize/forecast.tsx # interactivity +MOD frontend/src/pages/visualize/backtest.tsx # interactivity +MOD README.md # feature list +MOD docs/_base/API_CONTRACTS.md # +/analytics/inventory-status row +MOD docs/_base/REPO_MAP_INDEX.md # +demand.tsx row +KEEP app/main.py # UNCHANGED — analytics router wired +KEEP alembic/** # UNCHANGED — NO migration (read-only endpoint) +``` + +### Known Gotchas & Library Quirks +```python +# CRITICAL: NO Alembic migration. inventory_snapshot_daily already exists; the +# endpoint is a read-only SELECT. `.claude/rules` require a migration only when +# the SCHEMA changes. Adding one would be wrong. + +# CRITICAL: NO app/main.py change. The analytics router is already wired +# (main.py:17, 133). The new endpoint attaches to the existing analytics +# APIRouter in routes.py. + +# CRITICAL: Postgres DISTINCT ON. select(InventorySnapshotDaily) +# .order_by(store_id, product_id, date.desc()).distinct(store_id, product_id) +# → "SELECT DISTINCT ON (store_id, product_id) ... ORDER BY store_id, +# product_id, date DESC". The DISTINCT ON columns MUST lead the ORDER BY or +# Postgres errors. This yields the latest snapshot per grain. + +# CRITICAL: a `train` job's result.run_id is the ARTIFACT key +# (model_{run_id}.joblib), NOT a registry model_run id. forecasting/service.py +# never writes to the registry. So the in-page "Run new forecast" picks a +# completed `train` JOB and submits `{run_id: trainJob.result.run_id, +# horizon}` as a `predict` job. Do NOT use useRuns / a registry RunPicker — +# those ids will FileNotFoundError in _execute_predict. + +# CRITICAL: the analytics db_session cleanup (conftest.py:119-128) deletes +# SalesDaily, TEST- Store/Product, and Calendar 2024-01-01..2024-04-29 — it +# does NOT touch InventorySnapshotDaily. The sample_inventory fixture's rows +# FK-reference calendar.date / store.id / product.id, so the cleanup MUST gain +# `await session.execute(delete(InventorySnapshotDaily))` as the FIRST delete +# (before Store/Product/Calendar) or the run leaks rows / hits an FK error. +# Import InventorySnapshotDaily in conftest.py. + +# CRITICAL: a predict job defaults to horizon=14. "Next month" (30 d) is +# therefore usually a PARTIAL sum. demand-utils must return a +# `nextMonthPartial: boolean` and the table must badge it — never report a +# misleadingly low absolute number as if it were a full month. + +# GOTCHA: use the plain shadcn (components/ui/table.tsx) for the demand +# table, NOT the Explorer . DataTable is built for server-paginated +# data with `manualSorting:true`; the demand rows are derived client-side from +# completed predict jobs (≤50). A plain Table + a small useMemo client-side +# sort is correct and lower-risk. + +# GOTCHA: Job.result is typed `Record | null` (types/api.ts:188). +# Narrow defensively — `job.result?.forecasts as ForecastPoint[] | undefined` +# — exactly as forecast.tsx:17 already does. A SKU whose predict job has no +# inventory row must still render (requirement = "unknown", not a crash). + +# GOTCHA: the prediction-interval band needs recharts (Area + +# Line together). A plain cannot host an . Keep TimeSeriesChart +# backward-compatible: ComposedChart renders Lines identically; the band is +# gated behind `showInterval` (default false) so existing callers are unaffected. + +# GOTCHA: JobPicker's prop type is Extract — widen +# to Extract so the in-page-forecast +# train-job picker compiles. jobLabel() already handles any job. + +# GOTCHA: useCreateJob (use-jobs.ts:55-64) is a mutation returning the new Job; +# after .mutateAsync, set the page's searchJobId to job.job_id — useJob then +# auto-polls while pending/running (use-jobs.ts:48-52). + +# GOTCHA: new .ts/.tsx files are LF. Editing existing .py files must PRESERVE +# their CRLF (repo .py files are CRLF, no .gitattributes — project memory). +# After editing run `git diff --stat` and confirm no whole-file EOL churn. + +# GOTCHA: every commit references the open tracking issue (commit-format.md); +# NO AI co-author trailer, ever. Branch off `dev`. +``` + +### Resolved Decisions (user-confirmed 2026-05-18) +```yaml +keszletigeny-calculation: + decision: add the read-only GET /analytics/inventory-status endpoint; + inventory requirement = max(0, leadTimeDemand - on_hand_qty - on_order_qty). + status: confirmed (AskUserQuestion — "Add inventory endpoint"). +demand-page-scope: + decision: multi-SKU table aggregating ALL recent completed predict jobs, with + a single-SKU drill-in panel — not a single-SKU-only view. + status: confirmed (AskUserQuestion — "Multi-SKU table + drill-in"). +interactivity-scope: + decision: all four upgrades ship on the Forecast/Backtest pages — in-page + job submission, prediction-interval band, CSV export, run/job cross-links. + status: confirmed (AskUserQuestion — multi-select, all four). +ui-language: + decision: all UI copy is English. The Hungarian request terms map to + Tomorrow / Next week / Next month / SKU demand / Inventory requirement. + status: derived — every existing page in the repo is English. +``` + +--- + +## Implementation Blueprint + +### Data models — backend schemas (`app/features/analytics/schemas.py`) +```python +# Append after TimeSeriesResponse. Mirror TimeSeriesPoint/TimeSeriesResponse. +class InventoryStatusItem(BaseModel): + """Latest inventory snapshot for one (store, product) grain.""" + model_config = ConfigDict(from_attributes=True) + + store_id: int = Field(..., description="Store ID.") + product_id: int = Field(..., description="Product ID.") + date: date = Field(..., description="Snapshot date (latest available).") + on_hand_qty: int = Field(..., ge=0, description="Units on hand end-of-day.") + on_order_qty: int = Field(..., ge=0, description="Units inbound / on order.") + is_stockout: bool = Field(..., description="True when on_hand_qty is 0.") + + +class InventoryStatusResponse(BaseModel): + """Latest inventory snapshot per (store, product) grain.""" + items: list[InventoryStatusItem] = Field(..., description="One item per grain.") + total_items: int = Field(..., ge=0, description="Number of items returned.") + store_id: int | None = Field(None, description="Store filter applied, if any.") + product_id: int | None = Field(None, description="Product filter applied, if any.") +``` + +### Data models — frontend types (`frontend/src/types/api.ts`, after TimeSeriesResponse) +```typescript +// GET /analytics/inventory-status — latest snapshot per (store, product). +export interface InventoryStatusItem { + store_id: number + product_id: number + date: string // ISO date + on_hand_qty: number + on_order_qty: number + is_stockout: boolean +} +export interface InventoryStatusResponse { + items: InventoryStatusItem[] + total_items: number + store_id: number | null + product_id: number | null +} +// Client-derived view-model row for the Demand Planner table (camelCase — not a +// wire contract). One per completed predict job. +export interface DemandRow { + jobId: string + runId: string | null + storeId: number + productId: number + sku: string + productName: string + modelType: string + horizon: number + tomorrow: number + nextWeek: number + nextMonth: number + nextMonthPartial: boolean + onHand: number | null // null when no inventory snapshot exists + onOrder: number | null + isStockout: boolean + inventoryRequirement: number | null // null when stock is unknown + forecasts: { date: string; forecast: number; lower_bound?: number; upper_bound?: number }[] +} +``` + +### Backend service (`app/features/analytics/service.py`) +```python +# IMPORTS: add InventorySnapshotDaily to the data_platform import; add the two +# new schema names. `select` is already imported. + +async def compute_inventory_status( + self, db: AsyncSession, store_id: int | None = None, product_id: int | None = None, +) -> InventoryStatusResponse: + # PATTERN: compute_kpis (service.py:43-90) — async select + filter chaining. + stmt = ( + select(InventorySnapshotDaily) + .order_by( + InventorySnapshotDaily.store_id, + InventorySnapshotDaily.product_id, + InventorySnapshotDaily.date.desc(), + ) + .distinct(InventorySnapshotDaily.store_id, InventorySnapshotDaily.product_id) + ) + if store_id is not None: + stmt = stmt.where(InventorySnapshotDaily.store_id == store_id) + if product_id is not None: + stmt = stmt.where(InventorySnapshotDaily.product_id == product_id) + + rows = (await db.execute(stmt)).scalars().all() + items = [InventoryStatusItem.model_validate(r) for r in rows] + logger.info("analytics.inventory_status_computed", count=len(items), store_id=store_id) + return InventoryStatusResponse( + items=items, total_items=len(items), store_id=store_id, product_id=product_id, + ) +``` + +### Backend route (`app/features/analytics/routes.py`) +```python +# Add InventoryStatusResponse to the schema import block, then append: +@router.get( + "/inventory-status", + response_model=InventoryStatusResponse, + summary="Latest inventory snapshot per store/product", + description="""Return the most recent inventory_snapshot_daily row for each +(store, product) grain — on-hand units, on-order units, stockout flag. Optional +store_id / product_id filters. Returns an empty list (HTTP 200) when no +snapshots exist.""", +) +async def get_inventory_status( + store_id: int | None = Query(None, description="Filter by store ID."), + product_id: int | None = Query(None, description="Filter by product ID."), + db: AsyncSession = Depends(get_db), +) -> InventoryStatusResponse: + service = AnalyticsService() + return await service.compute_inventory_status( + db=db, store_id=store_id, product_id=product_id, + ) +``` + +### Frontend hook (`frontend/src/hooks/use-inventory.ts`) — mirror use-timeseries.ts +```typescript +import { useQuery, keepPreviousData } from '@tanstack/react-query' +import { api } from '@/lib/api' +import type { InventoryStatusResponse } from '@/types/api' + +interface UseInventoryParams { storeId?: number; productId?: number; enabled?: boolean } + +export function useInventoryStatus({ storeId, productId, enabled = true }: UseInventoryParams) { + return useQuery({ + queryKey: ['inventory-status', { storeId, productId }], + queryFn: () => api('/analytics/inventory-status', { + params: { store_id: storeId, product_id: productId }, + }), + placeholderData: keepPreviousData, + enabled, + }) +} +``` + +### Frontend pure utils (`frontend/src/lib/demand-utils.ts`) +```typescript +// All pure, no React. ForecastPoint = {date, forecast, lower_bound?, upper_bound?}. +export function sumWindow(forecasts: { forecast: number }[], days: number): number { + return forecasts.slice(0, days).reduce((s, p) => s + p.forecast, 0) +} +export function rollups(forecasts: { forecast: number }[]) { + return { + tomorrow: forecasts[0]?.forecast ?? 0, + nextWeek: sumWindow(forecasts, 7), + nextMonth: sumWindow(forecasts, 30), + nextMonthPartial: forecasts.length < 30, + } +} +// units to reorder; 0 when covered; null when stock is unknown. +export function inventoryRequirement( + leadTimeDemand: number, onHand: number | null, onOrder: number | null, +): number | null { + if (onHand === null) return null + return Math.max(0, Math.round(leadTimeDemand - onHand - (onOrder ?? 0))) +} +// joinDemandRows(predictJobs, products, inventoryItems, leadTimeDays) -> DemandRow[] +// - key inventory + product by (store_id, product_id) / product id into Maps +// - leadTimeDemand = sumWindow(forecasts, leadTimeDays) +// - skip predict jobs whose result has no forecasts array +``` + +### Demand Planner page (`frontend/src/pages/visualize/demand.tsx`) +```text +export default function DemandPlannerPage() +- const [leadTime, setLeadTime] = useState(14) // 7 | 14 | 30 +- const [selectedJobId, setSelectedJobId] = useState('') // drill-in target +- const jobsQuery = useJobs({ jobType:'predict', status:'completed', page:1, pageSize:50 }) +- const productsQuery = useProducts({ page:1, pageSize:500 }) +- const invQuery = useInventoryStatus({}) +- const rows: DemandRow[] = useMemo(() => joinDemandRows( + jobsQuery.data?.jobs ?? [], productsQuery.data?.products ?? [], + invQuery.data?.items ?? [], leadTime), [deps]) +- States: LoadingState while any query loads; ErrorDisplay on error; + EmptyState when rows.length === 0 ("No completed forecasts — run one on the + Forecast page", link to ROUTES.VISUALIZE.FORECAST). +- Header card:

Demand Planner

+ a lead-time
(components/ui/table.tsx): columns SKU · Product · Tomorrow + · Next week · Next month · On hand · Inventory need. Numbers via formatNumber. + Badge is_stockout (destructive) and nextMonthPartial ("partial"). Clickable + rows set selectedJobId. An "Export CSV" Button → downloadCsv('demand.csv', + toCsv(rows, [...CsvColumn])). +- Client-side sort: a useState sort key + a useMemo sorted copy; clickable + cells. +- Drill-in panel (Card, shown when selectedJobId set): the selected row's + TimeSeriesChart (data=forecasts, predictedKey="forecast", showActual=false), + a reorder breakdown (leadTimeDemand - onHand - onOrder = need, via the `Field` + helper pattern from run-detail.tsx), and cross-Links to + `/explorer/jobs/${jobId}` and (when runId) `/explorer/runs/${runId}`. +- Build with frontend-design + shadcn-ui. +``` + +### TimeSeriesChart interval band (`frontend/src/components/charts/time-series-chart.tsx`) +```text +- Add optional props: lowerKey?: string, upperKey?: string, showInterval = false. +- Swap (recharts) — Lines render identically. +- When showInterval && lowerKey && upperKey: render an whose dataKey is a + function returning [row[lowerKey], row[upperKey]] (recharts range area), low + fillOpacity, drawn BEFORE the forecast so the line sits on top. +- All current props/behaviour unchanged; existing callers omit the new props. +``` + +### Forecast page interactivity (`frontend/src/pages/visualize/forecast.tsx`) +```text +- "Run new forecast" Card: a JobPicker(jobType="train") to choose a completed + train job + a horizon (useStores), product (naive|seasonal_naive|moving_average), + , n_splits + test_size inputs (defaults 5 / 14) → useCreateJob() + .mutateAsync({ job_type:'backtest', params:{ model_type, store_id, product_id, + start_date, end_date, n_splits, test_size } }); on success point searchJobId + at the new job. (Re-verify the params keys against _execute_backtest first.) +- "Export CSV" Button → downloadCsv(`backtest-${jobId}.csv`, + toCsv(backtestResult.fold_metrics, [{key:'fold',header:'Fold'}, + {key:'mae',...},{key:'smape',...},{key:'wape',...},{key:'bias',...}])). +- Cross-link: Link the loaded job to `/explorer/jobs/${job.job_id}`. +``` + +### list of tasks (in execution order) +```yaml +Task 1 — Tracking GitHub issue + branch: + - Open ONE issue: "Visualize: Demand Planner page + interactive Forecast/ + Backtest pages". Scopes: analytics / ui / docs. + - Confirm `gh issue view --json state` → OPEN. Every commit references (#N). + - Branch: `git fetch origin && git switch -c feat/ui-visualize-demand-planner + origin/dev`. + +Task 2 — Backend: inventory schemas: + MODIFY app/features/analytics/schemas.py + - Append InventoryStatusItem + InventoryStatusResponse (see Blueprint). + VALIDATE: uv run ruff check app/features/analytics/schemas.py && + uv run mypy app/features/analytics/schemas.py + +Task 3 — Backend: service method: + MODIFY app/features/analytics/service.py + - Add `InventorySnapshotDaily` to the data_platform import; add the two + schema imports; add `compute_inventory_status` (see Blueprint). + VALIDATE: uv run mypy app/features/analytics/service.py && + uv run pyright app/features/analytics/service.py + +Task 4 — Backend: route: + MODIFY app/features/analytics/routes.py + - Add InventoryStatusResponse to the schema imports; add the + GET /inventory-status handler (see Blueprint). No validate_date_range. + VALIDATE: uv run ruff check app/features/analytics/ && uv run mypy app/ && + uv run pyright app/ && uv run python -c "from app.main import app" + +Task 5 — Backend tests: + MODIFY app/features/analytics/tests/conftest.py + - Import InventorySnapshotDaily. Add `delete(InventorySnapshotDaily)` as the + FIRST statement in the db_session cleanup (before SalesDaily/Store/ + Product/Calendar — FK order). Add a `sample_inventory` fixture inserting + >=2 snapshots for one grain on different dates (proves latest-wins) + a + second grain — depends on sample_store/sample_product/sample_calendar_120. + MODIFY app/features/analytics/tests/test_routes_integration.py + - Add TestAnalyticsInventoryStatusIntegration: latest-per-grain returned; + store_id filter narrows; empty DB → items:[]/total_items:0/HTTP 200. + MODIFY app/features/analytics/tests/test_schemas.py + - Unit tests for InventoryStatusItem/Response (valid + rejects negative qty). + GOTCHA: integration tests need `docker compose up -d` + `alembic upgrade head`. + VALIDATE: + docker compose up -d && uv run alembic upgrade head && + uv run pytest -v app/features/analytics/tests/ && + uv run pytest -v -m integration app/features/analytics/tests/ + +Task 6 — Frontend: types: + MODIFY frontend/src/types/api.ts — add InventoryStatusItem, + InventoryStatusResponse, DemandRow (see Blueprint). + VALIDATE: cd frontend && pnpm tsc --noEmit + +Task 7 — Frontend: hook: + CREATE frontend/src/hooks/use-inventory.ts (see Blueprint). + VALIDATE: cd frontend && pnpm tsc --noEmit + +Task 8 — Frontend: pure utils + tests: + CREATE frontend/src/lib/demand-utils.ts (see Blueprint). + CREATE frontend/src/lib/demand-utils.test.ts — cover empty forecasts, + horizon<30 (nextMonthPartial true), covered vs reorder inventory, the join + with a missing inventory row (onHand null → requirement null). + VALIDATE: cd frontend && pnpm test --run src/lib/demand-utils.test.ts + +Task 9 — Frontend: routing: + MODIFY frontend/src/lib/constants.ts — ROUTES.VISUALIZE += + DEMAND:'/visualize/demand'; add { label:'Demand Planner', + href:ROUTES.VISUALIZE.DEMAND } to the Visualize NAV_ITEMS submenu. + MODIFY frontend/src/App.tsx — lazy import DemandPlannerPage + a in + (mirror the FORECAST route block). + NOTE: pnpm tsc fails here until Task 10 — re-run after Task 10. + +Task 10 — Frontend: Demand Planner page: + CREATE frontend/src/pages/visualize/demand.tsx (see Blueprint). + Build with frontend-design + shadcn-ui; use the plain
, not DataTable. + VALIDATE: cd frontend && pnpm tsc --noEmit && pnpm lint + +Task 11 — Frontend: TimeSeriesChart interval band: + MODIFY frontend/src/components/charts/time-series-chart.tsx (see Blueprint). + GOTCHA: keep backward-compatible — existing callers omit the new props. If the + recharts range-Area API is uncertain, confirm via the contex7 MCP. + VALIDATE: cd frontend && pnpm tsc --noEmit && pnpm test --run + +Task 12 — Frontend: widen JobPicker + upgrade Forecast page: + MODIFY frontend/src/components/common/job-picker.tsx — widen `jobType` to + Extract. + MODIFY frontend/src/pages/visualize/forecast.tsx — in-page run (train-job + picker + horizon → predict job), interval toggle, CSV export, cross-links + (see Blueprint). + VALIDATE: cd frontend && pnpm tsc --noEmit && pnpm lint + +Task 13 — Frontend: upgrade Backtest page: + MODIFY frontend/src/pages/visualize/backtest.tsx — in-page backtest run, CSV + export of fold_metrics, job cross-link (see Blueprint). Re-read + _execute_backtest for the exact params keys before wiring the form. + VALIDATE: cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run + +Task 14 — Docs: + MODIFY README.md — mention the Demand Planner + interactive Visualize pages. + MODIFY docs/_base/API_CONTRACTS.md — add the GET /analytics/inventory-status + row to the analytics section. + MODIFY docs/_base/REPO_MAP_INDEX.md — add a demand.tsx frontend-page row. + +Task 15 — Dogfood the running UI (mandatory per ui-design.md): + - docker compose up -d ; uv run alembic upgrade head ; + uv run python scripts/seed_random.py --full-new --seed 42 --confirm. + - `make demo` (or POST a few train + predict + backtest /jobs) so the pages + have data. + - uv run uvicorn app.main:app --port 8123 & ; cd frontend && + ./node_modules/.bin/vite --host 0.0.0.0. + - Via webapp-testing / agent-browser exercise the 9 scenarios in Validation + Level 4. Capture screenshots of the Demand Planner + both upgraded pages. + +Task 16 — Commit + PR: + Branch feat/ui-visualize-demand-planner. Commits, each (#issue), no AI trailer: + 1. feat(analytics): add inventory-status endpoint (#N) + 2. test(analytics): cover inventory-status endpoint (#N) + 3. feat(ui): add demand-planner data layer — types, hook, demand-utils (#N) + 4. feat(ui): add Visualize Demand Planner page (#N) + 5. feat(ui): add prediction-interval band to TimeSeriesChart (#N) + 6. feat(ui): make Forecast and Backtest pages interactive (#N) + 7. docs(docs): document Visualize demand planner + interactivity (#N) + Open PR into dev; CI green; merge. +``` + +### Per-task pseudocode (highest-risk tasks) +```python +# Task 3 — the DISTINCT ON query (the one piece of non-trivial backend SQL) +stmt = ( + select(InventorySnapshotDaily) + .order_by( # DISTINCT ON cols MUST lead + InventorySnapshotDaily.store_id, + InventorySnapshotDaily.product_id, + InventorySnapshotDaily.date.desc(), # ...then date desc → latest + ) + .distinct( + InventorySnapshotDaily.store_id, + InventorySnapshotDaily.product_id, + ) +) +# filters compose after; .scalars().all() → list[InventorySnapshotDaily] +``` +```typescript +// Task 12 — the in-page forecast run (the genuinely new UI logic) +const createJob = useCreateJob() +async function runForecast(trainJob: Job, horizon: number) { + const runId = trainJob.result?.run_id // ARTIFACT key, not a registry id + if (typeof runId !== 'string') return // guard a malformed result + const job = await createJob.mutateAsync({ + job_type: 'predict', params: { run_id: runId, horizon }, + }) + setSearchJobId(job.job_id) // useJob then polls to completion +} +``` + +### Integration Points +```yaml +DATABASE: NONE — no migration. Read-only SELECT on inventory_snapshot_daily. +BACKEND: analytics slice — +1 GET endpoint, +1 service method, +2 schemas. + app/main.py UNCHANGED (analytics router already wired). +CONFIG: NONE — no new env var. +FRONTEND ROUTING: + - ROUTES.VISUALIZE.DEMAND (constants.ts) + a Visualize NAV_ITEMS entry. + - One lazy in App.tsx. +CI: + - No new workflow. ci.yml covers it. Because analytics .py files change, the + ruff/mypy/pyright/pytest jobs are load-bearing — keep green. +``` + +--- + +## Validation Loop + +### Level 1: Syntax & Style +```bash +uv run ruff check . && uv run ruff format --check . +cd frontend && pnpm lint +# Expected: zero errors. Fix before proceeding. +``` + +### Level 2: Type Checks +```bash +uv run mypy app/ && uv run pyright app/ # both --strict, both gate merge +cd frontend && pnpm tsc --noEmit +# Watch: the .distinct()/.scalars() typing and the InventoryStatusItem +# model_validate(orm_row) call are the most likely strict-mode snags. +``` + +### Level 3: Unit + Integration Tests +```bash +uv run pytest -v -m "not integration" +docker compose up -d && uv run alembic upgrade head +uv run pytest -v -m integration app/features/analytics/tests/ +cd frontend && pnpm test --run +# If integration tests fail on a stale local Postgres: +# docker compose down -v && docker compose up -d && uv run alembic upgrade head +``` + +### Level 4: Manual end-to-end (dogfood — REQUIRED, ui-design.md) +```bash +docker compose up -d && uv run alembic upgrade head +uv run python scripts/seed_random.py --full-new --seed 42 --confirm +make demo # populate jobs (train/predict/backtest) +uv run uvicorn app.main:app --port 8123 & +until curl -fs http://127.0.0.1:8123/health; do sleep 2; done +cd frontend && ./node_modules/.bin/vite --host 0.0.0.0 # http://localhost:5173 + +# Browser checks via webapp-testing / agent-browser: +# 1. curl "http://localhost:8123/analytics/inventory-status?store_id=1" → 200, +# one item per product, each carrying the LATEST date. +# 2. Visualize → Demand Planner lists SKU rows with tomorrow/next-week/ +# next-month + inventory need; a horizon-14 forecast badges "partial" month. +# 3. Change the lead-time selector 7→14→30 → the inventory-need column updates. +# 4. Click a demand row → drill-in shows the demand curve + reorder breakdown +# + working cross-links to the job and (if present) the run. +# 5. Export the demand table to CSV → file downloads with all rows. +# 6. Forecast page → "Run new forecast": pick a train job + horizon 30 → a new +# predict job runs and the chart renders a 30-day forecast. +# 7. Toggle "Show prediction interval" → a shaded band renders (or the toggle +# is disabled when the model has no bounds). +# 8. Backtest page → "Run new backtest": pick store/product/model/dates → a new +# backtest job runs and metrics render; export fold metrics to CSV. +# 9. Forecast/Backtest job cross-links navigate to /explorer/jobs/:id. +``` + +--- + +## Final validation Checklist +- [ ] `uv run ruff check . && uv run ruff format --check .` clean +- [ ] `uv run mypy app/ && uv run pyright app/` clean (both --strict) +- [ ] `uv run pytest -v -m "not integration"` green +- [ ] `uv run pytest -v -m integration app/features/analytics/tests/` green +- [ ] `cd frontend && pnpm tsc --noEmit && pnpm lint && pnpm test --run` clean +- [ ] `GET /analytics/inventory-status` returns latest-per-grain; honours + store_id/product_id; empty table → 200 + items:[] +- [ ] Demand Planner table renders SKU rows with tomorrow/next-week/next-month + + inventory requirement; lead-time selector recomputes the requirement +- [ ] Demand row drill-in shows the demand curve + reorder breakdown + cross-links +- [ ] Forecast page launches a predict job in-page; interval band + CSV work +- [ ] Backtest page launches a backtest job in-page; fold-metric CSV works +- [ ] No Alembic migration; no app/main.py change; no new slice; no .env var +- [ ] README + API_CONTRACTS.md + REPO_MAP_INDEX.md updated +- [ ] Branch `feat/ui-visualize-demand-planner`; every commit references the + Task-1 issue; no AI co-author trailer +- [ ] Demand Planner + both upgraded pages dogfooded in a real browser + (screenshots captured) + +--- + +## Anti-Patterns to Avoid +- ❌ Don't add an Alembic migration — the endpoint is a read-only SELECT; + `inventory_snapshot_daily` already exists. +- ❌ Don't edit `app/main.py` — the analytics router is already wired. +- ❌ Don't put `date.desc()` before `store_id`/`product_id` in the ORDER BY — + Postgres requires the DISTINCT ON columns to lead. +- ❌ Don't use a registry `useRuns` RunPicker for the in-page forecast — train + jobs do not create registry runs; submit the `train` job's `result.run_id`. +- ❌ Don't use the Explorer `DataTable` for the demand table — it is + server-paginated (`manualSorting:true`); use the plain `
` + a small + client-side sort. +- ❌ Don't report "next month" as a full-month number when `horizon < 30` — + return and badge `nextMonthPartial`. +- ❌ Don't forget the `delete(InventorySnapshotDaily)` cleanup line in the + analytics `db_session` fixture, before the Store/Product/Calendar deletes. +- ❌ Don't break `TimeSeriesChart`'s existing callers — the interval band is + opt-in (`showInterval` defaults false). +- ❌ Don't mock the database in integration tests; don't create test rows + without the `TEST-` store/product prefix the cleanup keys on. +- ❌ Don't hand-roll the page without `frontend-design` / `shadcn-ui`, and don't + claim "done" on a green `tsc` — dogfood in a real browser. +- ❌ Don't `git push --force` on dev/main; no AI co-author trailers; every + commit references the open issue. + +--- + +## Confidence Score + +**8 / 10** for one-pass implementation success. + +**Why solid:** +- The backend change is one read-only endpoint that mirrors `get_timeseries` + almost verbatim — one service method, one route, two schemas, no migration, + no `main.py` change, no new slice. +- The frontend reuses shipped infrastructure end-to-end: `use-timeseries.ts` is + the exact hook template, `csv-export.ts` / `TimeSeriesChart` / `JobPicker` / + `Table` / `DateRangePicker` / `useCreateJob` all already exist; the page + skeleton mirrors `forecast.tsx` and the drill-in mirrors `run-detail.tsx`. +- The demand math lives in a pure, fully unit-tested `demand-utils.ts` module — + the highest-logic part is the most testable. +- Every cited file carries verified line numbers; the real traps (DISTINCT ON + column order, train-job vs registry-run id for predict, the missing inventory + cleanup line, partial-month horizons, `ComposedChart` for the band) are each + called out with the exact fix. + +**Why not higher:** +- The in-page **backtest** form is the largest sub-task — store/product/model/ + date controls plus the `_execute_backtest` params contract; a key mismatch + there is the most likely second-pass fix (the PRP prescribes re-reading the + contract first). +- The `TimeSeriesChart` `LineChart`→`ComposedChart` migration plus a recharts + range-`Area` band is genuine shared-component surgery — backward-compatible by + design, but it needs the real-browser dogfood (Task 15) and possibly a + contex7 doc check to land cleanly. +- The Demand Planner is real UI composition — table + sort + drill-in + states; + a green `tsc` will not catch a cramped layout or a broken join. + +All identified risks are caught by the layered validation loop (strict +type-check → integration tests → browser dogfood) and the fixes are local. +Executing the 16 tasks in order and running each `VALIDATE` before moving on is +what carries it. diff --git a/README.md b/README.md index 3e6d6f28..27210c98 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Portfolio-grade end-to-end retail demand forecasting system. - **Model Registry**: Run configs, metrics, artifacts, and data windows for reproducibility - **Dashboard**: React 19 + Vite + Tailwind CSS 4 + shadcn/ui for data exploration and model management - **Explorer**: Click-through detail pages for stores, products, model runs, and jobs; run-vs-run comparison and SHA-256 artifact integrity verification; server-side sortable, CSV-exportable tables with column-visibility toggles and URL-shareable filter/sort/page state across every Explorer page; date-scoped KPIs, revenue bar/line charts, and cross-filtering on the Sales page +- **Demand Planner**: `/visualize/demand` — every completed forecast rolled into a multi-SKU table (tomorrow / next-week / next-month demand + inventory requirement), with a lead-time selector and a single-SKU drill-in; the Forecast and Backtest pages run jobs in-page, export CSV, toggle a prediction-interval band, and cross-link to runs/jobs - **RAG Knowledge Base**: Postgres pgvector embeddings + evidence-grounded answers with citations - **Agentic Layer**: PydanticAI agents for autonomous experimentation and evidence-grounded Q&A with human-in-the-loop approval - **Data Seeder (The Forge)**: Reproducible synthetic data generator with realistic time-series patterns, scenario presets, and retail effects diff --git a/app/features/analytics/routes.py b/app/features/analytics/routes.py index eab4b9fb..762d5204 100644 --- a/app/features/analytics/routes.py +++ b/app/features/analytics/routes.py @@ -15,6 +15,7 @@ from app.features.analytics.schemas import ( DrilldownDimension, DrilldownResponse, + InventoryStatusResponse, KPIResponse, TimeGranularity, TimeSeriesResponse, @@ -342,3 +343,64 @@ async def get_timeseries( product_id=product_id, category=category, ) + + +# ============================================================================= +# Inventory Status Endpoint +# ============================================================================= + + +@router.get( + "/inventory-status", + response_model=InventoryStatusResponse, + summary="Latest inventory snapshot per store/product", + description=""" +Return the most recent `inventory_snapshot_daily` row for each +(store, product) grain. + +**Purpose**: Surface current stock context — on-hand units, on-order units, +and the stockout flag — so a demand view can compute an inventory requirement. + +**Per grain**: the latest snapshot by date (`on_hand_qty`, `on_order_qty`, +`is_stockout`). + +**Filtering Options**: +- `store_id`: scope to a single store +- `product_id`: scope to a single product + +**Empty data**: returns HTTP 200 with `items: []` and `total_items: 0` when no +snapshots exist — never a 404. + +**Example Use Cases**: +1. All grains: `GET /analytics/inventory-status` +2. One store: `GET /analytics/inventory-status?store_id=5` +3. One grain: `GET /analytics/inventory-status?store_id=5&product_id=10` +""", +) +async def get_inventory_status( + store_id: int | None = Query( + None, + description="Filter by store ID. Use GET /dimensions/stores to find valid IDs.", + ), + product_id: int | None = Query( + None, + description="Filter by product ID. Use GET /dimensions/products to find valid IDs.", + ), + db: AsyncSession = Depends(get_db), +) -> InventoryStatusResponse: + """Return the latest inventory snapshot per (store, product) grain. + + Args: + store_id: Filter by store ID (optional). + product_id: Filter by product ID (optional). + db: Database session. + + Returns: + Latest snapshot per grain. Empty list when no snapshots exist. + """ + service = AnalyticsService() + return await service.compute_inventory_status( + db=db, + store_id=store_id, + product_id=product_id, + ) diff --git a/app/features/analytics/schemas.py b/app/features/analytics/schemas.py index f5528b33..8b973dee 100644 --- a/app/features/analytics/schemas.py +++ b/app/features/analytics/schemas.py @@ -4,6 +4,7 @@ with rich descriptions for LLM tool-calling. """ +import datetime from datetime import date from decimal import Decimal from enum import Enum @@ -254,6 +255,69 @@ class TimeSeriesResponse(BaseModel): ) +# ============================================================================= +# Inventory Status Response Schemas +# ============================================================================= + + +class InventoryStatusItem(BaseModel): + """Latest inventory snapshot for one (store, product) grain. + + Built from the most recent ``inventory_snapshot_daily`` row for the grain. + """ + + model_config = ConfigDict(from_attributes=True) + + store_id: int = Field(..., description="Store ID.") + product_id: int = Field(..., description="Product ID.") + # Annotated as ``datetime.date`` (not bare ``date``) because the field name + # ``date`` would otherwise shadow the imported ``date`` type. + date: datetime.date = Field( + ..., + description="Snapshot date (the latest available for this grain).", + ) + on_hand_qty: int = Field( + ..., + ge=0, + description="Units on hand at end of day.", + ) + on_order_qty: int = Field( + ..., + ge=0, + description="Units inbound / on order.", + ) + is_stockout: bool = Field( + ..., + description="True when the grain was out of stock (on_hand_qty is 0).", + ) + + +class InventoryStatusResponse(BaseModel): + """Latest inventory snapshot per (store, product) grain. + + One item per grain — the most recent snapshot. Returns an empty list + when no snapshots exist (never a 404). + """ + + items: list[InventoryStatusItem] = Field( + ..., + description="One item per (store, product) grain — the latest snapshot.", + ) + total_items: int = Field( + ..., + ge=0, + description="Number of items returned (equals len(items)).", + ) + store_id: int | None = Field( + None, + description="Store filter applied (if any). Null means all stores included.", + ) + product_id: int | None = Field( + None, + description="Product filter applied (if any). Null means all products included.", + ) + + # ============================================================================= # Date Range Validation # ============================================================================= diff --git a/app/features/analytics/service.py b/app/features/analytics/service.py index 51d6f17b..6518cb7b 100644 --- a/app/features/analytics/service.py +++ b/app/features/analytics/service.py @@ -18,13 +18,20 @@ DrilldownDimension, DrilldownItem, DrilldownResponse, + InventoryStatusItem, + InventoryStatusResponse, KPIMetrics, KPIResponse, TimeGranularity, TimeSeriesPoint, TimeSeriesResponse, ) -from app.features.data_platform.models import Product, SalesDaily, Store +from app.features.data_platform.models import ( + InventorySnapshotDaily, + Product, + SalesDaily, + Store, +) logger = get_logger(__name__) @@ -386,3 +393,58 @@ async def compute_timeseries( product_id=product_id, category=category, ) + + async def compute_inventory_status( + self, + db: AsyncSession, + store_id: int | None = None, + product_id: int | None = None, + ) -> InventoryStatusResponse: + """Return the latest inventory snapshot per (store, product) grain. + + Uses Postgres ``DISTINCT ON`` — the DISTINCT ON columns + (store_id, product_id) lead the ORDER BY, followed by ``date DESC``, + so the first row kept per grain is the most recent snapshot. + + Args: + db: Database session. + store_id: Filter by store ID (optional). + product_id: Filter by product ID (optional). + + Returns: + Latest snapshot per grain. An empty list when no snapshots exist. + """ + stmt = ( + select(InventorySnapshotDaily) + .order_by( + InventorySnapshotDaily.store_id, + InventorySnapshotDaily.product_id, + InventorySnapshotDaily.date.desc(), + ) + .distinct( + InventorySnapshotDaily.store_id, + InventorySnapshotDaily.product_id, + ) + ) + + if store_id is not None: + stmt = stmt.where(InventorySnapshotDaily.store_id == store_id) + if product_id is not None: + stmt = stmt.where(InventorySnapshotDaily.product_id == product_id) + + rows = (await db.execute(stmt)).scalars().all() + items = [InventoryStatusItem.model_validate(row) for row in rows] + + logger.info( + "analytics.inventory_status_computed", + count=len(items), + store_id=store_id, + product_id=product_id, + ) + + return InventoryStatusResponse( + items=items, + total_items=len(items), + store_id=store_id, + product_id=product_id, + ) diff --git a/app/features/analytics/tests/conftest.py b/app/features/analytics/tests/conftest.py index b0e07620..b101fabc 100644 --- a/app/features/analytics/tests/conftest.py +++ b/app/features/analytics/tests/conftest.py @@ -19,7 +19,13 @@ KPIMetrics, KPIResponse, ) -from app.features.data_platform.models import Calendar, Product, SalesDaily, Store +from app.features.data_platform.models import ( + Calendar, + InventorySnapshotDaily, + Product, + SalesDaily, + Store, +) from app.main import app @@ -116,7 +122,10 @@ async def db_session() -> AsyncGenerator[AsyncSession, None]: try: yield session finally: - # Clean up test data (delete in FK-safe order). + # Clean up test data (delete in FK-safe order). InventorySnapshotDaily + # FK-references store/product/calendar, so it must be cleared before + # the Store/Product/Calendar deletes below. + await session.execute(delete(InventorySnapshotDaily)) await session.execute(delete(SalesDaily)) await session.execute(delete(Product).where(Product.sku.like("TEST-%"))) await session.execute(delete(Store).where(Store.code.like("TEST-%"))) @@ -234,3 +243,69 @@ async def sample_sales_120( for sale in sales_records: await db_session.refresh(sale) return sales_records + + +@pytest.fixture +async def sample_inventory( + db_session: AsyncSession, + sample_store: Store, + sample_product: Product, + sample_calendar_120: list[Calendar], +) -> list[InventorySnapshotDaily]: + """Create inventory snapshots for two grains. + + Grain 1 (sample_store, sample_product): two snapshots on different dates, + so a test can prove the latest (2024-01-20) wins over the older one + (2024-01-10). Grain 2 (sample_store, a second TEST- product): a single + stockout snapshot. The second product is TEST-prefixed so the db_session + cleanup removes it. + """ + unique_id = uuid.uuid4().hex[:8] + product2 = Product( + sku=f"TEST-{unique_id}", + name="Test Product 2", + category="Test Category", + brand="Test Brand", + base_price=Decimal("29.99"), + base_cost=Decimal("14.99"), + ) + db_session.add(product2) + await db_session.commit() + await db_session.refresh(product2) + + snapshots = [ + # Grain 1 — older snapshot (must be superseded by the newer one). + InventorySnapshotDaily( + date=date(2024, 1, 10), + store_id=sample_store.id, + product_id=sample_product.id, + on_hand_qty=50, + on_order_qty=10, + is_stockout=False, + ), + # Grain 1 — newer snapshot (latest-per-grain must return this one). + InventorySnapshotDaily( + date=date(2024, 1, 20), + store_id=sample_store.id, + product_id=sample_product.id, + on_hand_qty=12, + on_order_qty=30, + is_stockout=False, + ), + # Grain 2 — single stockout snapshot. + InventorySnapshotDaily( + date=date(2024, 1, 15), + store_id=sample_store.id, + product_id=product2.id, + on_hand_qty=0, + on_order_qty=0, + is_stockout=True, + ), + ] + for snapshot in snapshots: + db_session.add(snapshot) + + await db_session.commit() + for snapshot in snapshots: + await db_session.refresh(snapshot) + return snapshots diff --git a/app/features/analytics/tests/test_routes_integration.py b/app/features/analytics/tests/test_routes_integration.py index 7a74e678..e3fd1aa6 100644 --- a/app/features/analytics/tests/test_routes_integration.py +++ b/app/features/analytics/tests/test_routes_integration.py @@ -10,8 +10,15 @@ import pytest from httpx import AsyncClient +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession -from app.features.data_platform.models import Product, SalesDaily, Store +from app.features.data_platform.models import ( + InventorySnapshotDaily, + Product, + SalesDaily, + Store, +) # Sum of quantities 1..120 = 7260; revenue = 9.99 * 7260. _EXPECTED_UNITS = 7260 @@ -178,3 +185,77 @@ async def test_drilldowns_smoke( data = response.json() assert data["dimension"] == "date" assert len(data["items"]) >= 1 + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestAnalyticsInventoryStatusIntegration: + """Integration tests for GET /analytics/inventory-status.""" + + async def test_inventory_status_returns_latest_per_grain( + self, + client: AsyncClient, + sample_store: Store, + sample_product: Product, + sample_inventory: list[InventorySnapshotDaily], + ) -> None: + """One item per grain, each carrying the latest snapshot for the grain.""" + response = await client.get( + "/analytics/inventory-status", + params={"store_id": sample_store.id}, + ) + + assert response.status_code == 200 + data = response.json() + # sample_inventory creates two grains under this store. + assert data["total_items"] == 2 + assert len(data["items"]) == 2 + assert data["store_id"] == sample_store.id + + # Grain 1 must return the newer (2024-01-20) snapshot, not the older one. + grain1 = next(i for i in data["items"] if i["product_id"] == sample_product.id) + assert grain1["date"] == "2024-01-20" + assert grain1["on_hand_qty"] == 12 + assert grain1["on_order_qty"] == 30 + assert grain1["is_stockout"] is False + + # Grain 2 is the stockout snapshot. + stockout = next(i for i in data["items"] if i["product_id"] != sample_product.id) + assert stockout["on_hand_qty"] == 0 + assert stockout["is_stockout"] is True + + async def test_inventory_status_product_filter_narrows( + self, + client: AsyncClient, + sample_store: Store, + sample_product: Product, + sample_inventory: list[InventorySnapshotDaily], + ) -> None: + """The product_id filter narrows the result to a single grain.""" + response = await client.get( + "/analytics/inventory-status", + params={"store_id": sample_store.id, "product_id": sample_product.id}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total_items"] == 1 + assert data["product_id"] == sample_product.id + assert data["items"][0]["product_id"] == sample_product.id + assert data["items"][0]["on_hand_qty"] == 12 + + async def test_inventory_status_empty_returns_200( + self, + client: AsyncClient, + db_session: AsyncSession, + ) -> None: + """An empty inventory table yields HTTP 200 with an empty list, not a 404.""" + await db_session.execute(delete(InventorySnapshotDaily)) + await db_session.commit() + + response = await client.get("/analytics/inventory-status") + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total_items"] == 0 diff --git a/app/features/analytics/tests/test_schemas.py b/app/features/analytics/tests/test_schemas.py index 1eb97a46..36e2747a 100644 --- a/app/features/analytics/tests/test_schemas.py +++ b/app/features/analytics/tests/test_schemas.py @@ -10,6 +10,8 @@ from pydantic import ValidationError from app.features.analytics.schemas import ( + InventoryStatusItem, + InventoryStatusResponse, KPIMetrics, TimeGranularity, TimeSeriesPoint, @@ -75,3 +77,57 @@ def test_time_series_response_rejects_negative_total_points() -> None: start_date=date(2024, 1, 1), end_date=date(2024, 1, 1), ) + + +def test_inventory_status_item_construct() -> None: + """An InventoryStatusItem carries the grain, snapshot date and quantities.""" + item = InventoryStatusItem( + store_id=1, + product_id=2, + date=date(2024, 1, 20), + on_hand_qty=12, + on_order_qty=30, + is_stockout=False, + ) + assert item.store_id == 1 + assert item.product_id == 2 + assert item.date == date(2024, 1, 20) + assert item.on_hand_qty == 12 + assert item.on_order_qty == 30 + assert item.is_stockout is False + + +def test_inventory_status_item_rejects_negative_qty() -> None: + """on_hand_qty / on_order_qty have a ge=0 constraint.""" + with pytest.raises(ValidationError): + InventoryStatusItem( + store_id=1, + product_id=2, + date=date(2024, 1, 20), + on_hand_qty=-1, + on_order_qty=0, + is_stockout=False, + ) + + +def test_inventory_status_response_construct() -> None: + """An InventoryStatusResponse aggregates items; filters default to None.""" + item = InventoryStatusItem( + store_id=1, + product_id=2, + date=date(2024, 1, 20), + on_hand_qty=0, + on_order_qty=0, + is_stockout=True, + ) + response = InventoryStatusResponse(items=[item], total_items=1) + assert response.total_items == 1 + assert response.items[0].is_stockout is True + assert response.store_id is None + assert response.product_id is None + + +def test_inventory_status_response_rejects_negative_total() -> None: + """total_items has a ge=0 constraint.""" + with pytest.raises(ValidationError): + InventoryStatusResponse(items=[], total_items=-1) diff --git a/docs/_base/API_CONTRACTS.md b/docs/_base/API_CONTRACTS.md index e5d864fa..00e93fb4 100644 --- a/docs/_base/API_CONTRACTS.md +++ b/docs/_base/API_CONTRACTS.md @@ -16,6 +16,7 @@ All endpoints serve JSON; error responses use `application/problem+json` (RFC 78 | analytics | GET | `/analytics/kpis` | Aggregated KPIs (revenue, units, transactions, avg unit price, avg basket) | | analytics | GET | `/analytics/drilldowns` | Group-by dimension: store / product / category / region / date | | analytics | GET | `/analytics/timeseries` | Period-bucketed sales series (`granularity` = day/week/month/quarter) for revenue-over-time charts; reuses `validate_date_range` (inverted/over-730-day ranges 400) | +| analytics | GET | `/analytics/inventory-status` | Latest `inventory_snapshot_daily` row per `(store, product)` grain (Postgres `DISTINCT ON`); optional `store_id`/`product_id` filters; `200` + empty list on an empty table (never `404`) | | featuresets | POST | `/featuresets/compute` | Compute time-safe features (lag/rolling/calendar, leakage-prevented) | | featuresets | POST | `/featuresets/preview` | Preview features with sample rows | | forecasting | POST | `/forecasting/train` | Train a model (naive / seasonal_naive / moving_average / lightgbm) | diff --git a/docs/_base/REPO_MAP_INDEX.md b/docs/_base/REPO_MAP_INDEX.md index eb251ee2..9347ab64 100644 --- a/docs/_base/REPO_MAP_INDEX.md +++ b/docs/_base/REPO_MAP_INDEX.md @@ -30,6 +30,7 @@ ForecastLabAI is a portfolio-grade, single-host retail-demand-forecasting system | [`frontend/src/pages/explorer/run-detail.tsx`](../../frontend/src/pages/explorer/run-detail.tsx) | The model-run detail page — profile, JSON config/metrics/runtime info, store/product cross-links, artifact integrity verify, compare link | Investigating a single model run | | [`frontend/src/pages/explorer/job-detail.tsx`](../../frontend/src/pages/explorer/job-detail.tsx) | The job detail page — profile, params/result JSON, error details, linked run, cancel action, live polling | Investigating a single job | | [`frontend/src/pages/explorer/run-compare.tsx`](../../frontend/src/pages/explorer/run-compare.tsx) | The run-comparison page — two run pickers, side-by-side profile, config_diff, metrics_diff with delta indicators; deep-linkable via `?a=&b=` | Comparing two model runs | +| [`frontend/src/pages/visualize/demand.tsx`](../../frontend/src/pages/visualize/demand.tsx) | The Demand Planner page — completed `predict` jobs rolled into a multi-SKU table (tomorrow/next-week/next-month demand + inventory requirement), lead-time selector, single-SKU drill-in | Answering "how much will this SKU sell, and do I have enough stock?" | | [`alembic/versions/`](../../alembic/versions/) | Six migrations through `d6e0f2g3h456_create_agent_session_table.py` | DB-schema questions, migration drift | | [`docs/ARCHITECTURE.md`](../ARCHITECTURE.md) | Phase-by-phase architecture narrative | High-level component reasoning | | [`docs/PHASE-index.md`](../PHASE-index.md) | Index of all 11 phase docs | Locating per-phase deep-dive | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bf953b13..82ad780b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ const JobsMonitorPage = lazy(() => import('@/pages/explorer/jobs')) const JobDetailPage = lazy(() => import('@/pages/explorer/job-detail')) const ForecastPage = lazy(() => import('@/pages/visualize/forecast')) const BacktestPage = lazy(() => import('@/pages/visualize/backtest')) +const DemandPlannerPage = lazy(() => import('@/pages/visualize/demand')) const ChatPage = lazy(() => import('@/pages/chat')) const KnowledgePage = lazy(() => import('@/pages/knowledge')) const GuidePage = lazy(() => import('@/pages/guide')) @@ -150,6 +151,14 @@ function App() { } /> + }> + + + } + /> - - {title} - {description && {description}} - - - - - - { - // Format date for display - const date = new Date(value) - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - }} - /> - - } /> - - {showActual && ( - - )} - {showPredicted && ( - - )} - - - - - ) -} +import { Area, CartesianGrid, ComposedChart, Legend, Line, XAxis, YAxis } from 'recharts' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +interface TimeSeriesDataPoint { + date: string + actual?: number + predicted?: number + // `null` is allowed so a forecast point's optional lower/upper bounds (which + // arrive as `null` for models that emit no interval) can be passed through. + [key: string]: string | number | null | undefined +} + +interface TimeSeriesChartProps { + title: string + description?: string + data: TimeSeriesDataPoint[] + actualKey?: string + predictedKey?: string + xAxisKey?: string + showActual?: boolean + showPredicted?: boolean + /** Row key for the lower bound of the optional prediction-interval band. */ + lowerKey?: string + /** Row key for the upper bound of the optional prediction-interval band. */ + upperKey?: string + /** Render a shaded band between lowerKey/upperKey. Default false (opt-in). */ + showInterval?: boolean + height?: number + className?: string +} + +export function TimeSeriesChart({ + title, + description, + data, + actualKey = 'actual', + predictedKey = 'predicted', + xAxisKey = 'date', + showActual = true, + showPredicted = true, + lowerKey, + upperKey, + showInterval = false, + height = 300, + className, +}: TimeSeriesChartProps) { + // The --chart-N vars are complete oklch() colours (Tailwind 4 / shadcn v4); + // reference them directly — wrapping in hsl() produces invalid CSS (black). + const chartConfig: ChartConfig = { + [actualKey]: { + label: 'Actual', + color: 'var(--chart-1)', + }, + [predictedKey]: { + label: 'Predicted', + color: 'var(--chart-2)', + }, + } + + return ( + + + {title} + {description && {description}} + + + + + + { + // Format date for display + const date = new Date(value) + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + }} + /> + + } /> + + {/* Prediction-interval band — drawn first so the forecast line sits + on top. A function dataKey returns the [lower, upper] tuple + recharts renders as a range area. */} + {showInterval && lowerKey && upperKey && ( + { + const lower = entry[lowerKey] + const upper = entry[upperKey] + return typeof lower === 'number' && typeof upper === 'number' + ? [lower, upper] + : null + }} + name="Prediction interval" + fill="var(--chart-2)" + fillOpacity={0.15} + stroke="none" + isAnimationActive={false} + /> + )} + {showActual && ( + + )} + {showPredicted && ( + + )} + + + + + ) +} diff --git a/frontend/src/components/common/job-picker.tsx b/frontend/src/components/common/job-picker.tsx index ed677c87..20fb350e 100644 --- a/frontend/src/components/common/job-picker.tsx +++ b/frontend/src/components/common/job-picker.tsx @@ -14,8 +14,8 @@ import { import type { Job, JobType } from '@/types/api' interface JobPickerProps { - /** Job type to list — 'predict' for forecasts, 'backtest' for backtests. */ - jobType: Extract + /** Job type to list — 'train', 'predict', or 'backtest'. */ + jobType: Extract /** Currently loaded job ID (empty string when nothing is loaded). */ selectedJobId: string /** Called with a job ID when the user picks one or enters one manually. */ diff --git a/frontend/src/hooks/use-inventory.ts b/frontend/src/hooks/use-inventory.ts new file mode 100644 index 00000000..30df8aeb --- /dev/null +++ b/frontend/src/hooks/use-inventory.ts @@ -0,0 +1,29 @@ +import { useQuery, keepPreviousData } from '@tanstack/react-query' +import { api } from '@/lib/api' +import type { InventoryStatusResponse } from '@/types/api' + +interface UseInventoryParams { + storeId?: number + productId?: number + enabled?: boolean +} + +/** GET /analytics/inventory-status — latest snapshot per (store, product) grain. */ +export function useInventoryStatus({ + storeId, + productId, + enabled = true, +}: UseInventoryParams) { + return useQuery({ + queryKey: ['inventory-status', { storeId, productId }], + queryFn: () => + api('/analytics/inventory-status', { + params: { + store_id: storeId, + product_id: productId, + }, + }), + placeholderData: keepPreviousData, + enabled, + }) +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index e6c749bc..d802c664 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -21,6 +21,7 @@ export const ROUTES = { VISUALIZE: { FORECAST: '/visualize/forecast', BACKTEST: '/visualize/backtest', + DEMAND: '/visualize/demand', }, KNOWLEDGE: '/knowledge', CHAT: '/chat', @@ -45,6 +46,7 @@ export const NAV_ITEMS = [ { label: 'Visualize', items: [ + { label: 'Demand Planner', href: ROUTES.VISUALIZE.DEMAND }, { label: 'Forecast', href: ROUTES.VISUALIZE.FORECAST }, { label: 'Backtest Results', href: ROUTES.VISUALIZE.BACKTEST }, ], diff --git a/frontend/src/lib/demand-utils.test.ts b/frontend/src/lib/demand-utils.test.ts new file mode 100644 index 00000000..e4294481 --- /dev/null +++ b/frontend/src/lib/demand-utils.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from 'vitest' +import { + extractForecasts, + inventoryRequirement, + joinDemandRows, + rollups, + sumWindow, +} from './demand-utils' +import type { InventoryStatusItem, Job, Product } from '@/types/api' + +/** Build a completed `predict` Job with the given result payload. */ +function makePredictJob( + jobId: string, + result: Record | null, + runId: string | null = null, +): Job { + return { + job_id: jobId, + job_type: 'predict', + status: 'completed', + params: {}, + result, + error_message: null, + error_type: null, + run_id: runId, + started_at: null, + completed_at: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } +} + +/** A flat forecast series of `count` days, each forecasting `perDay` units. */ +function flatForecasts(count: number, perDay: number): Array<{ date: string; forecast: number }> { + return Array.from({ length: count }, (_, i) => ({ + date: `2026-02-${String(i + 1).padStart(2, '0')}`, + forecast: perDay, + })) +} + +function makeProduct(id: number, sku: string, name: string): Product { + return { + id, + sku, + name, + category: 'Test', + brand: 'Test', + base_price: '9.99', + base_cost: '4.99', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } +} + +function makeInventory( + storeId: number, + productId: number, + onHand: number, + onOrder: number, +): InventoryStatusItem { + return { + store_id: storeId, + product_id: productId, + date: '2026-01-15', + on_hand_qty: onHand, + on_order_qty: onOrder, + is_stockout: onHand === 0, + } +} + +describe('sumWindow', () => { + it('sums the first N forecast points', () => { + expect(sumWindow(flatForecasts(10, 3), 4)).toBe(12) + }) + + it('clamps the window at the array length', () => { + expect(sumWindow(flatForecasts(5, 2), 30)).toBe(10) + }) + + it('returns 0 for an empty series', () => { + expect(sumWindow([], 7)).toBe(0) + }) +}) + +describe('rollups', () => { + it('returns zeros and a partial month for an empty series', () => { + const result = rollups([]) + expect(result.tomorrow).toBe(0) + expect(result.nextWeek).toBe(0) + expect(result.nextMonth).toBe(0) + expect(result.nextMonthPartial).toBe(true) + }) + + it('flags nextMonthPartial when the horizon is under 30 days', () => { + const result = rollups(flatForecasts(14, 5)) + expect(result.tomorrow).toBe(5) + expect(result.nextWeek).toBe(35) + expect(result.nextMonth).toBe(70) // only 14 days available + expect(result.nextMonthPartial).toBe(true) + }) + + it('does not flag a partial month for a 30+ day horizon', () => { + const result = rollups(flatForecasts(30, 2)) + expect(result.nextMonth).toBe(60) + expect(result.nextMonthPartial).toBe(false) + }) +}) + +describe('inventoryRequirement', () => { + it('returns 0 when on-hand + on-order cover demand', () => { + expect(inventoryRequirement(40, 30, 20)).toBe(0) + }) + + it('returns the shortfall when stock is insufficient', () => { + expect(inventoryRequirement(100, 30, 20)).toBe(50) + }) + + it('returns null when on-hand is unknown', () => { + expect(inventoryRequirement(100, null, null)).toBeNull() + }) + + it('treats a null on-order as zero', () => { + expect(inventoryRequirement(100, 30, null)).toBe(70) + }) +}) + +describe('extractForecasts', () => { + it('extracts a well-formed forecasts array', () => { + const job = makePredictJob('j1', { forecasts: flatForecasts(3, 7) }) + const forecasts = extractForecasts(job) + expect(forecasts).not.toBeNull() + expect(forecasts).toHaveLength(3) + expect(forecasts?.[0].forecast).toBe(7) + }) + + it('returns null when the result has no forecasts array', () => { + expect(extractForecasts(makePredictJob('j1', { store_id: 1 }))).toBeNull() + expect(extractForecasts(makePredictJob('j2', null))).toBeNull() + }) + + it('skips malformed entries', () => { + const job = makePredictJob('j1', { + forecasts: [{ date: '2026-02-01', forecast: 5 }, { date: '2026-02-02' }, null], + }) + expect(extractForecasts(job)).toHaveLength(1) + }) +}) + +describe('joinDemandRows', () => { + const products = [makeProduct(10, 'SKU-10', 'Widget'), makeProduct(20, 'SKU-20', 'Gadget')] + + it('skips predict jobs whose result has no forecasts', () => { + const jobs = [makePredictJob('empty', { store_id: 1, product_id: 10 })] + expect(joinDemandRows(jobs, products, [], 14)).toHaveLength(0) + }) + + it('builds a row joined to product and inventory', () => { + const job = makePredictJob('j1', { + store_id: 1, + product_id: 10, + model_type: 'naive', + horizon: 14, + forecasts: flatForecasts(14, 5), + }) + const inventory = [makeInventory(1, 10, 30, 10)] + const [row] = joinDemandRows([job], products, inventory, 14) + expect(row.sku).toBe('SKU-10') + expect(row.productName).toBe('Widget') + expect(row.tomorrow).toBe(5) + expect(row.nextWeek).toBe(35) + expect(row.onHand).toBe(30) + // leadTimeDemand = 14 * 5 = 70; requirement = 70 - 30 - 10 = 30 + expect(row.inventoryRequirement).toBe(30) + expect(row.nextMonthPartial).toBe(true) + }) + + it('yields a null requirement when no inventory snapshot exists for the grain', () => { + const job = makePredictJob('j1', { + store_id: 9, + product_id: 10, + forecasts: flatForecasts(14, 5), + }) + const [row] = joinDemandRows([job], products, [], 14) + expect(row.onHand).toBeNull() + expect(row.onOrder).toBeNull() + expect(row.inventoryRequirement).toBeNull() + }) + + it('falls back to a #id SKU when the product is unknown', () => { + const job = makePredictJob('j1', { + store_id: 1, + product_id: 999, + forecasts: flatForecasts(7, 3), + }) + const [row] = joinDemandRows([job], products, [], 14) + expect(row.sku).toBe('#999') + expect(row.productName).toBe('Unknown product') + }) +}) diff --git a/frontend/src/lib/demand-utils.ts b/frontend/src/lib/demand-utils.ts new file mode 100644 index 00000000..8be1b9fc --- /dev/null +++ b/frontend/src/lib/demand-utils.ts @@ -0,0 +1,133 @@ +/** + * Pure demand-rollup + inventory math for the Demand Planner page. + * + * No React, no I/O — every function here is unit-tested in demand-utils.test.ts. + * The Demand Planner composes these to turn completed `predict` jobs into a + * multi-SKU demand table. + */ +import type { DemandRow, ForecastPoint, InventoryStatusItem, Job, Product } from '@/types/api' + +/** Sum the `forecast` value of the first `days` points (clamped at the array length). */ +export function sumWindow(forecasts: { forecast: number }[], days: number): number { + return forecasts.slice(0, Math.max(0, days)).reduce((sum, point) => sum + point.forecast, 0) +} + +/** Tomorrow / next-week / next-month demand rollups for one forecast series. */ +export interface DemandRollups { + tomorrow: number + nextWeek: number + nextMonth: number + /** True when the horizon is shorter than 30 days — nextMonth is a partial sum. */ + nextMonthPartial: boolean +} + +/** Roll a daily forecast series up into tomorrow / next-week / next-month totals. */ +export function rollups(forecasts: { forecast: number }[]): DemandRollups { + return { + tomorrow: forecasts[0]?.forecast ?? 0, + nextWeek: sumWindow(forecasts, 7), + nextMonth: sumWindow(forecasts, 30), + nextMonthPartial: forecasts.length < 30, + } +} + +/** + * Units to reorder to cover `leadTimeDemand`. + * + * Returns 0 when on-hand + on-order already cover the demand, and `null` when + * stock is unknown (no inventory snapshot for the grain). + */ +export function inventoryRequirement( + leadTimeDemand: number, + onHand: number | null, + onOrder: number | null, +): number | null { + if (onHand === null) return null + return Math.max(0, Math.round(leadTimeDemand - onHand - (onOrder ?? 0))) +} + +/** + * Defensively extract a `predict` job's `result.forecasts` array. + * + * Job.result is `Record | null`; a job whose result has no + * usable forecasts array yields `null` (the caller skips it — never crashes). + */ +export function extractForecasts(job: Job): ForecastPoint[] | null { + const raw = job.result?.forecasts + if (!Array.isArray(raw)) return null + const points: ForecastPoint[] = [] + for (const entry of raw) { + if (entry && typeof entry === 'object') { + const record = entry as Record + if (typeof record.date === 'string' && typeof record.forecast === 'number') { + points.push({ + date: record.date, + forecast: record.forecast, + lower_bound: typeof record.lower_bound === 'number' ? record.lower_bound : null, + upper_bound: typeof record.upper_bound === 'number' ? record.upper_bound : null, + }) + } + } + } + return points +} + +/** + * Join completed `predict` jobs to products + the latest inventory snapshot + * into Demand Planner table rows. + * + * Jobs whose result has no usable forecasts are skipped. A grain with no + * inventory snapshot still produces a row (onHand/onOrder/requirement null). + */ +export function joinDemandRows( + predictJobs: Job[], + products: Product[], + inventory: InventoryStatusItem[], + leadTimeDays: number, +): DemandRow[] { + const productById = new Map(products.map((product) => [product.id, product])) + const inventoryByGrain = new Map( + inventory.map((item) => [`${item.store_id}:${item.product_id}`, item]), + ) + + const rows: DemandRow[] = [] + for (const job of predictJobs) { + const forecasts = extractForecasts(job) + if (!forecasts || forecasts.length === 0) continue + + const result = job.result ?? {} + const storeId = typeof result.store_id === 'number' ? result.store_id : 0 + const productId = typeof result.product_id === 'number' ? result.product_id : 0 + const modelType = typeof result.model_type === 'string' ? result.model_type : 'unknown' + const horizon = typeof result.horizon === 'number' ? result.horizon : forecasts.length + + const product = productById.get(productId) + const inventoryItem = inventoryByGrain.get(`${storeId}:${productId}`) + const onHand = inventoryItem ? inventoryItem.on_hand_qty : null + const onOrder = inventoryItem ? inventoryItem.on_order_qty : null + + const seriesRollups = rollups(forecasts) + const leadTimeDemand = sumWindow(forecasts, leadTimeDays) + + rows.push({ + jobId: job.job_id, + runId: typeof job.run_id === 'string' ? job.run_id : null, + storeId, + productId, + sku: product?.sku ?? `#${productId}`, + productName: product?.name ?? 'Unknown product', + modelType, + horizon, + tomorrow: seriesRollups.tomorrow, + nextWeek: seriesRollups.nextWeek, + nextMonth: seriesRollups.nextMonth, + nextMonthPartial: seriesRollups.nextMonthPartial, + onHand, + onOrder, + isStockout: inventoryItem ? inventoryItem.is_stockout : false, + inventoryRequirement: inventoryRequirement(leadTimeDemand, onHand, onOrder), + forecasts, + }) + } + return rows +} diff --git a/frontend/src/pages/visualize/backtest.tsx b/frontend/src/pages/visualize/backtest.tsx index 9a8b36d4..a2ce517f 100644 --- a/frontend/src/pages/visualize/backtest.tsx +++ b/frontend/src/pages/visualize/backtest.tsx @@ -1,202 +1,394 @@ -import { useState } from 'react' -import { useJob } from '@/hooks/use-jobs' -import { BacktestFoldsChart, MetricsSummary } from '@/components/charts/backtest-folds-chart' -import { EmptyState } from '@/components/common/error-display' -import { JobPicker } from '@/components/common/job-picker' -import { LoadingState } from '@/components/common/loading-state' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { LineChart } from 'lucide-react' - -interface BacktestResult { - aggregated_metrics: { - mae_mean: number - smape_mean: number - wape_mean: number - bias_mean: number - stability_index: number - } - fold_metrics: Array<{ - fold: number - mae: number - smape: number - wape: number - bias: number - }> - baseline_comparison?: { - naive: { mae: number; improvement_pct: number } - seasonal_naive: { mae: number; improvement_pct: number } - } -} - -export default function BacktestPage() { - const [searchJobId, setSearchJobId] = useState('') - const [selectedMetric, setSelectedMetric] = useState<'mae' | 'smape' | 'wape' | 'bias'>('mae') - - const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - - // Extract backtest result from job - const backtestResult = job?.result as BacktestResult | undefined - - return ( -
-

Backtest Results

- - {/* Job picker */} - - - Load Backtest - - Pick a completed backtest job to visualize the results - - - - - - - - {/* Results */} - {isLoading && } - - {error && ( - - -

- Failed to load job. Please check the job ID and try again. -

-
-
- )} - - {job && backtestResult && !isLoading && ( - <> - {/* Aggregated Metrics */} - - - Aggregated Metrics - - Mean metrics across all {backtestResult.fold_metrics?.length ?? 0} folds - - - - - - - - {/* Baseline Comparison */} - {backtestResult.baseline_comparison && ( - - - Baseline Comparison - Performance vs naive baselines - - -
-
-

vs Naive

-

- {backtestResult.baseline_comparison.naive.improvement_pct > 0 ? '+' : ''} - {backtestResult.baseline_comparison.naive.improvement_pct.toFixed(1)}% -

-

- Naive MAE: {backtestResult.baseline_comparison.naive.mae.toFixed(2)} -

-
-
-

vs Seasonal Naive

-

- {backtestResult.baseline_comparison.seasonal_naive.improvement_pct > 0 ? '+' : ''} - {backtestResult.baseline_comparison.seasonal_naive.improvement_pct.toFixed(1)}% -

-

- Seasonal MAE: {backtestResult.baseline_comparison.seasonal_naive.mae.toFixed(2)} -

-
-
-
-
- )} - - {/* Fold Metrics Chart */} - {backtestResult.fold_metrics && backtestResult.fold_metrics.length > 0 && ( - - - Metrics by Fold - Performance variation across CV folds - - - setSelectedMetric(v as typeof selectedMetric)}> - - MAE - sMAPE - WAPE - Bias - - - - - - - - )} - - )} - - {job && !backtestResult && !isLoading && ( - - -

- {job.status !== 'completed' - ? `Job is ${job.status}. Results will be available when completed.` - : 'This job does not contain backtest results.'} -

-
-
- )} - - {!searchJobId && !isLoading && ( - } - /> - )} -
- ) -} +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { format } from 'date-fns' +import { DateRange } from 'react-day-picker' +import { Download, ExternalLink, LineChart, Loader2, Play } from 'lucide-react' +import { useJob, useCreateJob } from '@/hooks/use-jobs' +import { useStores } from '@/hooks/use-stores' +import { useProducts } from '@/hooks/use-products' +import { BacktestFoldsChart, MetricsSummary } from '@/components/charts/backtest-folds-chart' +import { DateRangePicker } from '@/components/common/date-range-picker' +import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' +import { LoadingState } from '@/components/common/loading-state' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { downloadCsv, toCsv, type CsvColumn } from '@/lib/csv-export' +import { getErrorMessage } from '@/lib/api' + +interface FoldMetric { + fold: number + mae: number + smape: number + wape: number + bias: number +} + +interface BacktestResult { + aggregated_metrics: { + mae_mean: number + smape_mean: number + wape_mean: number + bias_mean: number + stability_index: number + } + fold_metrics: FoldMetric[] + baseline_comparison?: { + naive: { mae: number; improvement_pct: number } + seasonal_naive: { mae: number; improvement_pct: number } + } +} + +/** Forecasting models a backtest can run (LightGBM needs a trained run, excluded). */ +const MODEL_OPTIONS = [ + { value: 'naive', label: 'Naive' }, + { value: 'seasonal_naive', label: 'Seasonal Naive' }, + { value: 'moving_average', label: 'Moving Average' }, +] + +const foldCsvColumns: CsvColumn[] = [ + { key: 'fold', header: 'Fold' }, + { key: 'mae', header: 'MAE' }, + { key: 'smape', header: 'sMAPE' }, + { key: 'wape', header: 'WAPE' }, + { key: 'bias', header: 'Bias' }, +] + +export default function BacktestPage() { + const [searchJobId, setSearchJobId] = useState('') + const [selectedMetric, setSelectedMetric] = useState<'mae' | 'smape' | 'wape' | 'bias'>('mae') + + // In-page "Run new backtest" form state. + const [storeId, setStoreId] = useState('') + const [productId, setProductId] = useState('') + const [modelType, setModelType] = useState('naive') + const [dateRange, setDateRange] = useState() + const [nSplits, setNSplits] = useState(5) + const [testSize, setTestSize] = useState(14) + const [runError, setRunError] = useState(null) + + const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) + const createJob = useCreateJob() + // /dimensions/{stores,products} both cap page_size at 100. + const storesQuery = useStores({ page: 1, pageSize: 100 }) + const productsQuery = useProducts({ page: 1, pageSize: 100 }) + + // Extract backtest result from job + const backtestResult = job?.result as BacktestResult | undefined + + const formReady = !!storeId && !!productId && !!dateRange?.from && !!dateRange?.to + + async function handleRunBacktest() { + if (!storeId || !productId || !dateRange?.from || !dateRange?.to) return + setRunError(null) + try { + const newJob = await createJob.mutateAsync({ + job_type: 'backtest', + params: { + model_type: modelType, + store_id: Number(storeId), + product_id: Number(productId), + start_date: format(dateRange.from, 'yyyy-MM-dd'), + end_date: format(dateRange.to, 'yyyy-MM-dd'), + n_splits: nSplits, + test_size: testSize, + }, + }) + setSearchJobId(newJob.job_id) + } catch (caught) { + setRunError(getErrorMessage(caught)) + } + } + + function handleExport() { + if (!backtestResult?.fold_metrics || !job) return + downloadCsv( + `backtest-${job.job_id}.csv`, + toCsv(backtestResult.fold_metrics, foldCsvColumns), + ) + } + + return ( +
+

Backtest Results

+ + {/* Run a new backtest in-page */} + + + Run a new backtest + + Pick a store, product, model and date window to run time-series cross-validation. + + + +
+
+ Store + +
+
+ Product + +
+
+ Model + +
+
+ Date window + +
+
+ Splits + setNSplits(Number(event.target.value) || 0)} + /> +
+
+ Test size (days) + setTestSize(Number(event.target.value) || 0)} + /> +
+
+
+ + {!formReady && ( + + Pick a store, product and date window to enable. + + )} +
+ {runError &&

{runError}

} +
+
+ + {/* Load an existing backtest */} + + + Load Backtest + + Pick a completed backtest job to visualize the results + + + + + + + + {/* Results */} + {isLoading && } + + {error && ( + + +

+ Failed to load job. Please check the job ID and try again. +

+
+
+ )} + + {job && backtestResult && !isLoading && ( + <> + {/* Aggregated Metrics */} + + +
+
+ Aggregated Metrics + + Mean metrics across all {backtestResult.fold_metrics?.length ?? 0} folds + +
+
+ + +
+
+
+ + + +
+ + {/* Baseline Comparison */} + {backtestResult.baseline_comparison && ( + + + Baseline Comparison + Performance vs naive baselines + + +
+
+

vs Naive

+

+ {backtestResult.baseline_comparison.naive.improvement_pct > 0 ? '+' : ''} + {backtestResult.baseline_comparison.naive.improvement_pct.toFixed(1)}% +

+

+ Naive MAE: {backtestResult.baseline_comparison.naive.mae.toFixed(2)} +

+
+
+

vs Seasonal Naive

+

+ {backtestResult.baseline_comparison.seasonal_naive.improvement_pct > 0 ? '+' : ''} + {backtestResult.baseline_comparison.seasonal_naive.improvement_pct.toFixed(1)}% +

+

+ Seasonal MAE: {backtestResult.baseline_comparison.seasonal_naive.mae.toFixed(2)} +

+
+
+
+
+ )} + + {/* Fold Metrics Chart */} + {backtestResult.fold_metrics && backtestResult.fold_metrics.length > 0 && ( + + + Metrics by Fold + Performance variation across CV folds + + + setSelectedMetric(v as typeof selectedMetric)}> + + MAE + sMAPE + WAPE + Bias + + + + + + + + )} + + )} + + {job && !backtestResult && !isLoading && ( + + +

+ {job.status !== 'completed' + ? `Job is ${job.status}. Results will be available when completed.` + : 'This job does not contain backtest results.'} +

+
+
+ )} + + {!searchJobId && !isLoading && ( + } + /> + )} +
+ ) +} diff --git a/frontend/src/pages/visualize/demand.tsx b/frontend/src/pages/visualize/demand.tsx new file mode 100644 index 00000000..46f549d5 --- /dev/null +++ b/frontend/src/pages/visualize/demand.tsx @@ -0,0 +1,429 @@ +import { useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ArrowUpDown, ChevronDown, ChevronUp, Download, ExternalLink, Package } from 'lucide-react' +import { useJobs } from '@/hooks/use-jobs' +import { useProducts } from '@/hooks/use-products' +import { useInventoryStatus } from '@/hooks/use-inventory' +import { joinDemandRows, sumWindow } from '@/lib/demand-utils' +import { TimeSeriesChart } from '@/components/charts/time-series-chart' +import { EmptyState, ErrorDisplay } from '@/components/common/error-display' +import { LoadingState } from '@/components/common/loading-state' +import { StatusBadge } from '@/components/common/status-badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { downloadCsv, toCsv, type CsvColumn } from '@/lib/csv-export' +import { formatNumber } from '@/lib/api' +import { cn } from '@/lib/utils' +import { ROUTES } from '@/lib/constants' +import type { DemandRow } from '@/types/api' + +/** Lead-time presets (days) for the inventory-requirement calculation. */ +const LEAD_TIME_OPTIONS = [7, 14, 30] + +/** Demand-table columns the user can sort by (a subset of DemandRow keys). */ +type SortKey = 'sku' | 'tomorrow' | 'nextWeek' | 'nextMonth' | 'onHand' | 'inventoryRequirement' + +const csvColumns: CsvColumn[] = [ + { key: 'sku', header: 'SKU' }, + { key: 'productName', header: 'Product' }, + { key: 'modelType', header: 'Model' }, + { key: 'horizon', header: 'Horizon' }, + { key: 'tomorrow', header: 'Tomorrow' }, + { key: 'nextWeek', header: 'Next week' }, + { key: 'nextMonth', header: 'Next month' }, + { key: 'onHand', header: 'On hand' }, + { key: 'onOrder', header: 'On order' }, + { key: 'inventoryRequirement', header: 'Inventory need' }, + { key: 'isStockout', header: 'Stockout' }, +] + +/** A labelled value pair, matching the run-detail page's Field helper. */ +function Field({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +/** A clickable, sortable demand-table header cell. */ +function SortHead({ + label, + columnKey, + sortKey, + sortDir, + onSort, + numeric = false, +}: { + label: string + columnKey: SortKey + sortKey: SortKey + sortDir: 'asc' | 'desc' + onSort: (key: SortKey) => void + numeric?: boolean +}) { + const active = sortKey === columnKey + return ( + onSort(columnKey)} + > + + {label} + {active ? ( + sortDir === 'asc' ? ( + + ) : ( + + ) + ) : ( + + )} + + + ) +} + +/** A headline metric tile for the drill-in panel. */ +function MetricTile({ label, value, partial }: { label: string; value: number; partial?: boolean }) { + return ( +
+

{label}

+

{formatNumber(value)}

+ {partial && ( + + partial + + )} +
+ ) +} + +export default function DemandPlannerPage() { + const navigate = useNavigate() + const [leadTime, setLeadTime] = useState(14) + const [selectedJobId, setSelectedJobId] = useState('') + const [sortKey, setSortKey] = useState('inventoryRequirement') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + + const jobsQuery = useJobs({ page: 1, pageSize: 50, jobType: 'predict', status: 'completed' }) + // /dimensions/products caps page_size at 100; the demo's product count fits. + const productsQuery = useProducts({ page: 1, pageSize: 100 }) + const inventoryQuery = useInventoryStatus({}) + + const rows = useMemo( + () => + joinDemandRows( + jobsQuery.data?.jobs ?? [], + productsQuery.data?.products ?? [], + inventoryQuery.data?.items ?? [], + leadTime, + ), + [jobsQuery.data, productsQuery.data, inventoryQuery.data, leadTime], + ) + + const sortedRows = useMemo(() => { + const copy = [...rows] + copy.sort((a, b) => { + const av = a[sortKey] + const bv = b[sortKey] + // Unknown values (null inventory) always sort last. + if (av === null) return 1 + if (bv === null) return -1 + const cmp = + typeof av === 'string' && typeof bv === 'string' + ? av.localeCompare(bv) + : Number(av) - Number(bv) + return sortDir === 'asc' ? cmp : -cmp + }) + return copy + }, [rows, sortKey, sortDir]) + + const isLoading = jobsQuery.isLoading || productsQuery.isLoading || inventoryQuery.isLoading + const error = jobsQuery.error ?? productsQuery.error ?? inventoryQuery.error + const selectedRow = rows.find((row) => row.jobId === selectedJobId) + + function handleSort(key: SortKey) { + if (key === sortKey) { + setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc')) + } else { + setSortKey(key) + setSortDir(key === 'sku' ? 'asc' : 'desc') + } + } + + function handleExport() { + downloadCsv('demand-planner.csv', toCsv(sortedRows, csvColumns)) + } + + if (error) { + return ( +
+

Demand Planner

+ +
+ ) + } + + if (isLoading) { + return ( +
+

Demand Planner

+ +
+ ) + } + + return ( +
+
+

Demand Planner

+

+ Every completed forecast rolled up into tomorrow / next-week / next-month demand, + joined to current stock to show what needs reordering. +

+
+ + {rows.length === 0 ? ( + } + action={{ + label: 'Go to Forecast', + onClick: () => navigate(ROUTES.VISUALIZE.FORECAST), + }} + /> + ) : ( + <> + + + SKU demand + + {rows.length} forecast{rows.length === 1 ? '' : 's'}. The inventory requirement + covers demand over the selected lead time. + + + +
+
+ Lead time + +
+ +
+ +
+ + + + Product + + + + + + + + + {sortedRows.map((row) => ( + setSelectedJobId(row.jobId)} + className={cn( + 'cursor-pointer', + row.jobId === selectedJobId && 'bg-muted', + )} + > + + + {row.sku} + {row.isStockout && ( + stockout + )} + + + {row.productName} + {formatNumber(row.tomorrow)} + {formatNumber(row.nextWeek)} + + + {formatNumber(row.nextMonth)} + {row.nextMonthPartial && ( + partial + )} + + + + {row.onHand === null ? '—' : formatNumber(row.onHand)} + + + {row.inventoryRequirement === null + ? '—' + : formatNumber(row.inventoryRequirement)} + + + ))} + +
+ + + + {selectedRow && ( + + +
+
+ {selectedRow.sku} + + {selectedRow.productName} · {selectedRow.modelType} ·{' '} + {selectedRow.horizon}-day horizon + +
+
+ + {selectedRow.runId && ( + + )} +
+
+
+ +
+ + + +
+ +
+

Reorder breakdown

+
+ + + + +
+
+ + +
+
+ )} + + )} + + ) +} diff --git a/frontend/src/pages/visualize/forecast.tsx b/frontend/src/pages/visualize/forecast.tsx index c2081fe2..bda6f244 100644 --- a/frontend/src/pages/visualize/forecast.tsx +++ b/frontend/src/pages/visualize/forecast.tsx @@ -1,127 +1,256 @@ -import { useState } from 'react' -import { useJob } from '@/hooks/use-jobs' -import { TimeSeriesChart } from '@/components/charts/time-series-chart' -import { EmptyState } from '@/components/common/error-display' -import { JobPicker } from '@/components/common/job-picker' -import { LoadingState } from '@/components/common/loading-state' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { BarChart3 } from 'lucide-react' - -export default function ForecastPage() { - const [searchJobId, setSearchJobId] = useState('') - - const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - - // Extract forecast data from job result. - // A completed `predict` job stores result.forecasts (each point: date + forecast). - const forecastData = job?.result?.forecasts as Array<{ - date: string - forecast: number - }> | undefined - - return ( -
-

Forecast Visualization

- - {/* Job picker */} - - - Load Forecast - - Pick a completed prediction job to visualize the forecast - - - - - - - - {/* Results */} - {isLoading && } - - {error && ( - - -

- Failed to load job. Please check the job ID and try again. -

-
-
- )} - - {job && !isLoading && ( - <> - {/* Job Details */} - - - Job Details - - -
-
-
Job ID
-
{job.job_id.substring(0, 12)}...
-
-
-
Status
-
{job.status}
-
-
-
Type
-
{job.job_type}
-
-
-
Model
-
{String(job.params?.model_type ?? '-')}
-
-
-
-
- - {/* Forecast Chart */} - {forecastData && forecastData.length > 0 ? ( - - ) : job.status === 'completed' && job.job_type === 'predict' ? ( - - -

- No prediction data available in job result. -

-
-
- ) : ( - - -

- {job.status !== 'completed' - ? `Job is ${job.status}. Forecast will be available when completed.` - : 'This job type does not contain forecast data.'} -

-
-
- )} - - )} - - {!searchJobId && !isLoading && ( - } - /> - )} -
- ) -} +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { BarChart3, Download, ExternalLink, Loader2, Play } from 'lucide-react' +import { useJob, useCreateJob } from '@/hooks/use-jobs' +import { TimeSeriesChart } from '@/components/charts/time-series-chart' +import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' +import { LoadingState } from '@/components/common/loading-state' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { downloadCsv, toCsv, type CsvColumn } from '@/lib/csv-export' +import { getErrorMessage } from '@/lib/api' +import type { ForecastPoint } from '@/types/api' + +/** Horizon presets (days) for an in-page predict run. */ +const HORIZON_OPTIONS = [7, 14, 30, 60, 90] + +const csvColumns: CsvColumn[] = [ + { key: 'date', header: 'Date' }, + { key: 'forecast', header: 'Forecast' }, + { key: 'lower_bound', header: 'Lower' }, + { key: 'upper_bound', header: 'Upper' }, +] + +export default function ForecastPage() { + const [searchJobId, setSearchJobId] = useState('') + const [trainJobId, setTrainJobId] = useState('') + const [horizon, setHorizon] = useState(14) + const [showInterval, setShowInterval] = useState(false) + const [runError, setRunError] = useState(null) + + const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) + const { data: trainJob } = useJob(trainJobId, !!trainJobId) + const createJob = useCreateJob() + + // A completed `train` job stores result.run_id — the model-artifact key a + // `predict` job consumes. (This is NOT a registry run id.) + const trainRunId = + typeof trainJob?.result?.run_id === 'string' ? trainJob.result.run_id : null + + // A completed `predict` job stores result.forecasts (date + forecast, plus + // optional lower/upper bounds for models that emit a prediction interval). + const forecastData = job?.result?.forecasts as ForecastPoint[] | undefined + const hasBounds = !!forecastData?.some( + (point) => point.lower_bound != null && point.upper_bound != null, + ) + + async function handleRunForecast() { + if (!trainRunId) return + setRunError(null) + try { + const newJob = await createJob.mutateAsync({ + job_type: 'predict', + params: { run_id: trainRunId, horizon }, + }) + setSearchJobId(newJob.job_id) + } catch (caught) { + setRunError(getErrorMessage(caught)) + } + } + + function handleExport() { + if (!forecastData || !job) return + downloadCsv(`forecast-${job.job_id}.csv`, toCsv(forecastData, csvColumns)) + } + + return ( +
+

Forecast Visualization

+ + {/* Run a new forecast in-page */} + + + Run a new forecast + + Pick a completed training job and a horizon to generate a new prediction. + + + + +
+
+ Horizon + +
+ +
+ {trainJobId && !trainRunId && ( +

+ The selected training job has no model artifact — pick a completed train job. +

+ )} + {runError &&

{runError}

} +
+
+ + {/* Load an existing forecast */} + + + Load Forecast + + Pick a completed prediction job to visualize the forecast + + + + + + + + {/* Results */} + {isLoading && } + + {error && ( + + +

+ Failed to load job. Please check the job ID and try again. +

+
+
+ )} + + {job && !isLoading && ( + <> + {/* Job Details */} + + +
+ Job Details + +
+
+ +
+
+
Job ID
+
{job.job_id.substring(0, 12)}...
+
+
+
Status
+
{job.status}
+
+
+
Type
+
{job.job_type}
+
+
+
Model
+
{String(job.result?.model_type ?? job.params?.model_type ?? '-')}
+
+
+
+
+ + {/* Forecast Chart */} + {forecastData && forecastData.length > 0 ? ( +
+
+ + +
+ +
+ ) : job.status === 'completed' && job.job_type === 'predict' ? ( + + +

+ No prediction data available in job result. +

+
+
+ ) : ( + + +

+ {job.status !== 'completed' + ? `Job is ${job.status}. Forecast will be available when completed.` + : 'This job type does not contain forecast data.'} +

+
+
+ )} + + )} + + {!searchJobId && !isLoading && ( + } + /> + )} +
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ecd51d02..d2a16e42 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -97,6 +97,57 @@ export interface TimeSeriesResponse { category: string | null } +// One forecast point from a completed `predict` job's result.forecasts array. +// lower_bound / upper_bound are present only for models that emit intervals. +export interface ForecastPoint { + date: string // ISO date + forecast: number + lower_bound?: number | null + upper_bound?: number | null +} + +// One item of GET /analytics/inventory-status — the latest inventory snapshot +// for one (store, product) grain. +export interface InventoryStatusItem { + store_id: number + product_id: number + date: string // ISO date (latest snapshot) + on_hand_qty: number + on_order_qty: number + is_stockout: boolean +} + +// Response from GET /analytics/inventory-status (one item per grain). +export interface InventoryStatusResponse { + items: InventoryStatusItem[] + total_items: number + store_id: number | null + product_id: number | null +} + +// Client-derived view-model row for the Demand Planner table (camelCase — NOT +// a wire contract). One per completed `predict` job. `onHand`/`onOrder`/ +// `inventoryRequirement` are null when no inventory snapshot exists for the grain. +export interface DemandRow { + jobId: string + runId: string | null + storeId: number + productId: number + sku: string + productName: string + modelType: string + horizon: number + tomorrow: number + nextWeek: number + nextMonth: number + nextMonthPartial: boolean + onHand: number | null + onOrder: number | null + isStockout: boolean + inventoryRequirement: number | null + forecasts: ForecastPoint[] +} + // One day of a product's lifecycle demand curve. export interface LifecyclePoint { date: string // ISO date