diff --git a/.github/workflows/forecast-refresh.yml b/.github/workflows/forecast-refresh.yml index a274251..44bacd0 100644 --- a/.github/workflows/forecast-refresh.yml +++ b/.github/workflows/forecast-refresh.yml @@ -1,7 +1,7 @@ name: Forecast Refresh on: schedule: - - cron: '0 1 * * *' # 01:00 UTC — C-02, Guard 8 cascade + - cron: '0 7 * * 1' # Mondays 07:00 UTC (09:00 Berlin) — Phase 15-09 D-16 weekly cadence; preserves Guard 8 cascade workflow_dispatch: inputs: models: diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index e1349d2..d268e36 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -2,6 +2,7 @@ name: DB Migrations (DEV) on: push: branches: [main] + workflow_dispatch: jobs: push: runs-on: ubuntu-latest diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 54bbbf9..9eaba5d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -45,7 +45,7 @@ A restaurant owner opens the site on their phone and makes a real business decis - [x] **Phase 12: Foundation — Decisions & Guards** — ITS validity audit script + CI grep guard (`tenant_id` → `restaurant_id`) + UTC-anchored cron schedule contract - [x] **Phase 13: External Data Ingestion** — 5 ingest tables (weather/holidays/school/transit/events) + pipeline_runs + shop_calendar + GHA workflow + backfill from 2025-06-11 - [x] **Phase 14: Forecasting Engine — BAU Track** — SARIMAX/Prophet/ETS/Theta/Naive nightly fits + sample-path resampling + last_7_eval + forecast_daily_mv -- [ ] **Phase 15: Forecast Chart UI** — RevenueForecastCard + horizon/legend toggles + hover popup + event markers + 3 deferred `/api/*` endpoints + 375px QA +- [~] **Phase 15: Forecast Chart UI** — v2 (Forecast Backtest Overlay) impl 15-09..15-15 complete on branch `feature/phase-15-forecast-backtest-overlay`; 15-16 (DEV deploy + PR) pending user authorization; 15-17 (retire dedicated cards) deferred - [ ] **Phase 16: ITS Uplift Attribution** — campaign_calendar + Track-B counterfactual fit + campaign_uplift_v + CampaignUpliftCard with honest "CI overlaps zero" labeling - [ ] **Phase 17: Backtest Gate & Quality Monitoring** — rolling-origin CV at 4 horizons + ConformalIntervals + ≥10% RMSE promotion gate + freshness-SLO badges + ACCURACY-LOG diff --git a/.planning/STATE.md b/.planning/STATE.md index 1c4ff7d..37f2260 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,15 +2,15 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: External Data & Forecasting Foundation -status: "Phase 14 shipped" -stopped_at: Phase 14 (Forecasting Engine — BAU Track) shipped 2026-04-30. PR #22 merged, 17 migrations applied to DEV, weather backfill complete (1622 rows + 365 climatology norms), GHA pipeline passing (5/5 models x 2 KPIs x 365 days). forecast_quality populates after 2nd nightly run. Ready for Phase 15 (Forecast Chart UI). -last_updated: "2026-04-30T00:00:00Z" +status: "Phase 15 v2 implementation complete (DEV deploy + PR pending user authorization)" +stopped_at: Phase 15 v2 (Forecast Backtest Overlay) plans 15-09 through 15-15 implemented locally on branch feature/phase-15-forecast-backtest-overlay. 19 commits, all unit tests pass, svelte-check clean at 6-error baseline. Migration 0057 (granularity column) applied locally. Plan 15-16 partial — STATE/ROADMAP closure done; localhost gate deferred (no Phase 14 forecast seed data on local DB causes /api/forecast 500 on cold start); DEV deploy + PR creation paused for user authorization. Plan 15-17 (retire dedicated forecast cards) remains deferred per CONTEXT.md. +last_updated: "2026-05-01T00:00:00Z" progress: total_phases: 17 completed_phases: 14 - total_plans: 64 + total_plans: 73 completed_plans: 61 - percent: 82 + percent: 84 --- # STATE: Ramen Bones Analytics diff --git a/.planning/phases/15-forecast-backtest-overlay/15-09-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-09-PLAN.md new file mode 100644 index 0000000..fb1686b --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-09-PLAN.md @@ -0,0 +1,164 @@ +# Phase 15-09: Schema Migration — `granularity` column on `forecast_daily` + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox `- [ ]` syntax. + +**Goal:** Add `granularity text NOT NULL CHECK (granularity IN ('day','week','month'))` to `forecast_daily`, update PK + MV + wrapper view to include it, switch the forecast-refresh cron from nightly to weekly Monday morning. Replay-safe migration; backfills existing rows to `'day'`. + +**Architecture:** One new SQL migration file. Three downstream artifacts adjust: `forecast_daily_mv` (rebuild with new PK), `forecast_with_actual_v` (re-create with granularity passthrough), `.github/workflows/forecast-refresh.yml` (cron schedule edit). + +**Tech Stack:** PostgreSQL 15, Supabase migrations, GitHub Actions YAML. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `supabase/migrations/0057_forecast_daily_granularity.sql` | **Create** | Adds column with default `'day'`, backfills existing rows, drops default, bumps NOT NULL, redrops + recreates PK to include granularity. Rebuilds `forecast_daily_mv` and `forecast_with_actual_v`. | +| `.github/workflows/forecast-refresh.yml` | **Modify** | Cron `0 1 * * *` (nightly) → `0 7 * * 1` (Monday 07:00 UTC). | +| `tests/integration/forecast_daily_granularity.test.ts` | **Create** | Asserts: column exists with CHECK constraint; PK includes granularity; existing rows backfill to `'day'`; MV + view expose `granularity`. | + +--- + +### Task 1: Migration file + +- [ ] **Step 1: Create `supabase/migrations/0057_forecast_daily_granularity.sql`** + +```sql +-- 0057_forecast_daily_granularity.sql +-- Phase 15 v2 D-14: per-grain forecasts. Each refresh writes 3 rows per +-- (model, target_date) — one for each (day, week, month) granularity — +-- so the chart can show a daily forecast trained at last_actual−7d AND +-- a weekly forecast trained at last_actual−5w AND a monthly forecast +-- trained at end-of-month−5mo, all from the same forecast_daily table. +-- +-- Backfill safety: ALTER ADD COLUMN with DEFAULT is O(rows) on PG12+. +-- forecast_daily currently holds Phase 14's nightly runs (all daily +-- grain), so DEFAULT 'day' produces correct historical labelling. +-- Then DROP DEFAULT so future inserts must specify granularity. + +ALTER TABLE public.forecast_daily + ADD COLUMN IF NOT EXISTS granularity text NOT NULL DEFAULT 'day' + CHECK (granularity IN ('day', 'week', 'month')); + +ALTER TABLE public.forecast_daily ALTER COLUMN granularity DROP DEFAULT; + +-- Drop + recreate PK to include granularity in the natural key. +-- Existing key was (restaurant_id, kpi_name, target_date, model_name, run_date, forecast_track). +ALTER TABLE public.forecast_daily DROP CONSTRAINT forecast_daily_pkey; +ALTER TABLE public.forecast_daily ADD PRIMARY KEY + (restaurant_id, kpi_name, target_date, model_name, granularity, run_date, forecast_track); + +-- Rebuild forecast_daily_mv with granularity in select + unique index. +DROP MATERIALIZED VIEW IF EXISTS public.forecast_daily_mv CASCADE; + +CREATE MATERIALIZED VIEW public.forecast_daily_mv AS +SELECT DISTINCT ON (restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track) + restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track, + run_date, yhat, yhat_lower, yhat_upper, horizon_days, exog_signature +FROM public.forecast_daily +ORDER BY restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track, run_date DESC; + +CREATE UNIQUE INDEX forecast_daily_mv_uq + ON public.forecast_daily_mv + (restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track); + +REVOKE ALL ON public.forecast_daily_mv FROM authenticated, anon; + +-- Rebuild forecast_with_actual_v to include granularity passthrough. +-- Actual is keyed by business_date (daily-grain only); for weekly/monthly +-- forecasts the consumer (Phase 15 v2 endpoint) joins to k via target_date +-- which is already the bucket-start date for those grains, so the LEFT JOIN +-- works for daily but produces NULL actual_value for weekly/monthly rows +-- whose target_date doesn't land on a daily kpi_daily_mv row. The Phase +-- 15-11 endpoint handles that by building actuals from kpi_daily_mv directly +-- for the back-test window. +CREATE OR REPLACE VIEW public.forecast_with_actual_v AS +SELECT + f.restaurant_id, f.kpi_name, f.target_date, f.model_name, f.granularity, f.forecast_track, + f.run_date, f.yhat, f.yhat_lower, f.yhat_upper, f.horizon_days, f.exog_signature, + CASE f.kpi_name + WHEN 'revenue_eur' THEN k.revenue_cents / 100.0 + WHEN 'invoice_count' THEN k.tx_count::double precision + END AS actual_value +FROM public.forecast_daily_mv f +LEFT JOIN public.kpi_daily_mv k + ON k.restaurant_id = f.restaurant_id + AND k.business_date = f.target_date +WHERE f.restaurant_id = (auth.jwt()->>'restaurant_id')::uuid; + +GRANT SELECT ON public.forecast_with_actual_v TO authenticated; +``` + +- [ ] **Step 2: Apply migration locally + run Phase 14 integration tests** + +```bash +supabase db reset # local DB; replays all migrations cleanly +npm run test:integration -- forecast_daily 2>&1 | tail -10 +``` + +Expected: clean replay, Phase 14's existing integration tests still pass (they don't reference granularity yet so the new column is silently `'day'` for all rows the tests insert). + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/0057_forecast_daily_granularity.sql +git commit -m "feat(15-09): add granularity column to forecast_daily (D-14)" +``` + +--- + +### Task 2: Cron schedule change + +- [ ] **Step 1: Edit `.github/workflows/forecast-refresh.yml`** + +Replace `cron: '0 1 * * *'` with `cron: '0 7 * * 1'`. Update the comment header to note the cadence change is per Phase 15-09 D-16. + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/forecast-refresh.yml +git commit -m "feat(15-09): switch forecast-refresh to weekly Monday cron (D-16)" +``` + +--- + +### Task 3: Integration test for granularity + +- [ ] **Step 1: Write `tests/integration/forecast_daily_granularity.test.ts`** asserting: + - `granularity` column exists with CHECK constraint + - PK includes granularity (query `pg_constraint` for the 7-column key) + - Inserting without granularity fails (NOT NULL violation) + - Inserting with `granularity='hourly'` fails (CHECK violation) + - Existing rows from Phase 14 fixtures are visible at `granularity='day'` + - `forecast_with_actual_v` exposes granularity column + +- [ ] **Step 2: Run test** + +```bash +npm run test:integration -- forecast_daily_granularity 2>&1 | tail -10 +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/forecast_daily_granularity.test.ts +git commit -m "test(15-09): integration tests for forecast_daily granularity column" +``` + +--- + +## Verification (end of plan) + +- [ ] `supabase db reset` replays cleanly +- [ ] Phase 14 integration tests still pass (no regressions on the existing forecast_daily contract) +- [ ] CI guards pass (`npm run test:guards` — no raw `_mv` references introduced; the new view is the wrapper surface) + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-14 (grain-specific TRAIN_ENDs schema) | Task 1 | +| D-16 (weekly cron) | Task 2 | + +This plan is **prerequisite for** 15-10 (run_all.py writes rows with granularity values), 15-11 (endpoint queries by granularity column instead of resampling). diff --git a/.planning/phases/15-forecast-backtest-overlay/15-10-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-10-PLAN.md new file mode 100644 index 0000000..dfbf9fd --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-10-PLAN.md @@ -0,0 +1,208 @@ +# Phase 15-10: Model Fit Amendment — 3 Grain-Specific TRAIN_ENDs + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** `scripts/forecast/run_all.py` runs each BAU model 3 times per refresh per KPI per restaurant: daily / weekly / monthly. Each run uses a grain-specific `TRAIN_END` and produces forecasts at native bucket cadence. All rows write the matching `granularity` discriminator (added by 15-09). + +**Architecture:** `run_all.py` becomes the loop driver. Each per-model fit script (`sarimax_fit.py`, `prophet_fit.py`, `ets_fit.py`, `theta_fit.py`, `naive_dow_fit.py`) gains a `granularity` parameter and an aggregation helper that bucket-aggregates raw daily input → weekly/monthly before fitting. Output rows carry the granularity column set by `run_all.py`. + +**Tech Stack:** Python 3.12, pandas, statsmodels, prophet, neuralprophet (existing Phase 14 stack). + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `scripts/forecast/run_all.py` | **Modify** | Outer loop: for each (kpi, granularity, model) → compute TRAIN_END → bucket-aggregate input → fit → write rows with granularity discriminator. | +| `scripts/forecast/aggregation.py` | **Create** | `bucket_to_weekly(df)` and `bucket_to_monthly(df)` helpers. ISO week (Monday start) for weekly; first-of-month for monthly. Aggregation rule: `revenue_eur` → sum of daily values; `invoice_count` → sum of daily counts. | +| `scripts/forecast/sarimax_fit.py` | **Modify** | Accept `granularity` param + use it when forecasting horizon (372 day / 57 week / 17 month). Set output rows' `granularity` field. | +| `scripts/forecast/prophet_fit.py` | **Modify** | Same. | +| `scripts/forecast/ets_fit.py` | **Modify** | Same. | +| `scripts/forecast/theta_fit.py` | **Modify** | Same. | +| `scripts/forecast/naive_dow_fit.py` | **Modify** | Same. The naive baseline at weekly grain becomes naive-week-of-year (52-period seasonal); at monthly grain becomes naive-month-of-year (12-period). | +| `tests/forecast/test_aggregation.py` | **Create** | Unit-tests `bucket_to_weekly` (ISO Monday start; sum aggregation) and `bucket_to_monthly` (first-of-month; sum aggregation). | +| `tests/forecast/test_run_all_grain_loop.py` | **Create** | Asserts `run_all.py` produces 3 grain-specific TRAIN_END computations and writes rows tagged with each granularity. | + +--- + +### Task 1: Aggregation helpers + +- [ ] **Step 1: Write `tests/forecast/test_aggregation.py`** with the failing tests: + +```python +# tests/forecast/test_aggregation.py +import pandas as pd +from datetime import date +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly + +def test_bucket_to_weekly_iso_monday_start(): + # 2026-04-26 is a Sunday; 2026-04-27 is a Monday (start of new ISO week) + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-04-20', '2026-04-21', '2026-04-26', '2026-04-27']), + 'revenue_eur': [100, 150, 200, 175] + }) + out = bucket_to_weekly(df, value_col='revenue_eur') + assert len(out) == 2 + # Week starting 2026-04-20 (Mon): 100 + 150 + 200 = 450 + # Week starting 2026-04-27 (Mon): 175 + assert out.iloc[0]['week_start'] == pd.Timestamp('2026-04-20') + assert out.iloc[0]['revenue_eur'] == 450 + assert out.iloc[1]['revenue_eur'] == 175 + +def test_bucket_to_monthly_first_of_month_start(): + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-03-31', '2026-04-01', '2026-04-30', '2026-05-01']), + 'invoice_count': [10, 12, 15, 8] + }) + out = bucket_to_monthly(df, value_col='invoice_count') + assert len(out) == 3 + months = sorted(out['month_start'].dt.strftime('%Y-%m-%d').tolist()) + assert months == ['2026-03-01', '2026-04-01', '2026-05-01'] + +def test_bucket_to_weekly_excludes_partial_week(): + # When the input ends mid-week, the partial trailing week is dropped + # by the consumer (run_all.py uses TRAIN_END as the cutoff). + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-04-19']), # Sun (last day of prior week) + 'revenue_eur': [100] + }) + out = bucket_to_weekly(df, value_col='revenue_eur') + assert len(out) == 1 + assert out.iloc[0]['week_start'] == pd.Timestamp('2026-04-13') # Mon +``` + +- [ ] **Step 2: Run tests — RED** + +```bash +pytest tests/forecast/test_aggregation.py -v 2>&1 | tail -10 +``` + +- [ ] **Step 3: Implement `scripts/forecast/aggregation.py`** + +```python +# scripts/forecast/aggregation.py +# Phase 15 v2 D-14: bucket daily input into weekly (ISO Mon-start) or +# monthly (first-of-month) for grain-specific model fits. Sum aggregation +# matches the user's mental model: weekly revenue = sum of 7 daily values; +# monthly invoice_count = sum of all in-month transactions. +import pandas as pd + +def bucket_to_weekly(df: pd.DataFrame, *, value_col: str) -> pd.DataFrame: + """Aggregate `df` (must have business_date column) into ISO-week buckets keyed by Monday start.""" + out = df.copy() + # Floor to ISO-Monday week start. + out['week_start'] = out['business_date'] - pd.to_timedelta(out['business_date'].dt.weekday, unit='D') + g = out.groupby('week_start', as_index=False)[value_col].sum() + return g.rename(columns={'week_start': 'week_start'}) + +def bucket_to_monthly(df: pd.DataFrame, *, value_col: str) -> pd.DataFrame: + """Aggregate `df` into calendar-month buckets keyed by first-of-month.""" + out = df.copy() + out['month_start'] = out['business_date'].dt.to_period('M').dt.start_time + g = out.groupby('month_start', as_index=False)[value_col].sum() + return g +``` + +- [ ] **Step 4: Run tests — GREEN** + +```bash +pytest tests/forecast/test_aggregation.py -v 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add scripts/forecast/aggregation.py tests/forecast/test_aggregation.py +git commit -m "feat(15-10): bucket-to-weekly + bucket-to-monthly helpers (D-14)" +``` + +--- + +### Task 2: `run_all.py` grain loop + +- [ ] **Step 1: Read current `scripts/forecast/run_all.py` to understand the existing loop shape (single TRAIN_END = yesterday).** + +- [ ] **Step 2: Refactor `run_all.py` to loop over `granularity in ('day', 'week', 'month')`**: + +```python +# scripts/forecast/run_all.py — relevant new logic (sketch) +from datetime import timedelta +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly + +GRAIN_CONFIG = { + 'day': {'lookback_days': 7, 'horizon_periods': 372, 'aggregator': None}, + 'week': {'lookback_days': 35, 'horizon_periods': 57, 'aggregator': bucket_to_weekly}, + 'month': {'lookback_days': None, 'horizon_periods': 17, 'aggregator': bucket_to_monthly}, # special: end-of-month−5 +} + +def compute_train_end(last_actual_date, granularity): + if granularity == 'day': + return last_actual_date - timedelta(days=7) + if granularity == 'week': + return last_actual_date - timedelta(days=35) + if granularity == 'month': + # End of (last_actual.month - 5 calendar months). + # E.g. last_actual=2026-04-26 → end of (2026-04 - 5mo) = 2025-11-30 + from dateutil.relativedelta import relativedelta + target_month = (last_actual_date.replace(day=1) - relativedelta(months=5)) + # Last day of that month + next_month_first = target_month + relativedelta(months=1) + return next_month_first - timedelta(days=1) + +# In the model-loop: +for granularity, cfg in GRAIN_CONFIG.items(): + train_end = compute_train_end(last_actual_date, granularity) + bucketed = cfg['aggregator'](raw_df, value_col=kpi) if cfg['aggregator'] else raw_df + train_df = bucketed[bucketed[date_col] <= train_end] + for model_name, fit_fn in MODELS.items(): + rows = fit_fn(train_df, horizon_periods=cfg['horizon_periods'], granularity=granularity) + write_to_supabase(rows) # rows include granularity field +``` + +- [ ] **Step 3: Add a freshness gate at the top of `run_all.py`**: + +```python +# Abort if data is older than 8 days — weekly cadence allowance + 1d slack. +days_since_last = (date.today() - last_actual_date).days +if days_since_last > 8: + pipeline_runs_writer.write(step_name='forecast_run_all', status='waiting_for_data', + error_msg=f'last_actual={last_actual_date} stale by {days_since_last}d') + sys.exit(0) +``` + +- [ ] **Step 4: Update each per-model `*_fit.py` to accept `granularity` and pass through to row dicts.** All 5 models. Naive baseline gets seasonality switch: + - daily → 7-period (DoW) + - weekly → 52-period (week-of-year) + - monthly → 12-period (month-of-year) + +- [ ] **Step 5: Run integration test against the local Supabase TEST project** + +```bash +npm run test:integration -- forecast_run_all 2>&1 | tail -10 +``` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/forecast/ tests/forecast/ +git commit -m "feat(15-10): 3-grain TRAIN_END loop in run_all.py (D-14)" +``` + +--- + +## Verification + +- [ ] `pytest tests/forecast/` all green +- [ ] After a manual run on TEST DB, `forecast_daily` contains rows for all 3 granularities × 5 models × 2 KPIs +- [ ] `npm run test:guards` clean + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-14 grain-specific TRAIN_ENDs | Task 2 | +| D-18 dual-KPI parity (revenue_eur + invoice_count) | Task 2 (the existing run_all loops over both KPIs; this plan extends with granularity loop) | +| D-16 weekly freshness gate | Task 2 step 3 | + +**Prerequisite for** 15-11 (endpoint reads native-grain rows). diff --git a/.planning/phases/15-forecast-backtest-overlay/15-11-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-11-PLAN.md new file mode 100644 index 0000000..2e19b10 --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-11-PLAN.md @@ -0,0 +1,218 @@ +# Phase 15-11: `/api/forecast` Refactor — Native-Grain + `?kpi=` + Backtest Window + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** `/api/forecast` queries `forecast_with_actual_v` by native granularity (no client/server resampling). Adds `?kpi=revenue_eur|invoice_count` param. Returns `actuals` array extended back into the back-test window so the chart can show actuals (bars) and forecasts (lines) overlapping in the back-test region. + +**Architecture:** Edit existing `+server.ts` to query by granularity column. Drop the resampling step. Read actuals from `kpi_daily_v` (the wrapper view) for the back-test window separately and merge into the response. Drop `src/lib/forecastResampling.ts` + its test (no longer needed). + +**Tech Stack:** SvelteKit RequestHandler, Supabase, Vitest. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/routes/api/forecast/+server.ts` | **Modify** | Drop resampling. Query forecast rows by `granularity` column. Add `?kpi=` param (default `revenue_eur`). Build actuals from `kpi_daily_v` for back-test window. | +| `src/lib/forecastResampling.ts` | **Delete** | No longer needed; forecasts stored at native grain. | +| `tests/unit/forecastResampling.test.ts` | **Delete** | Same. | +| `src/lib/forecastValidation.ts` | **Modify** | Drop `isValidCombo` clamp matrix and `DEFAULT_GRANULARITY` (no clamp needed); keep `parseHorizon` + `parseGranularity` for input validation. | +| `tests/unit/forecastValidation.test.ts` | **Modify** | Remove clamp-matrix tests; keep parse tests. | +| `tests/unit/apiEndpoints.test.ts` | **Modify** | Update `/api/forecast` block to test new shape: `?kpi` param, native-grain query, actuals-extended response. | + +--- + +### Task 1: Drop the resampler + +- [ ] **Step 1: Delete files** + +```bash +rm src/lib/forecastResampling.ts tests/unit/forecastResampling.test.ts +``` + +- [ ] **Step 2: Slim down `src/lib/forecastValidation.ts`** — keep `parseHorizon`, `parseGranularity`, `HORIZON_DAYS`, `GRANULARITIES`. Remove `isValidCombo`, `DEFAULT_GRANULARITY`, `Horizon` type. Update test to match. + +- [ ] **Step 3: Run tests** + +```bash +npm run test:unit -- forecastValidation 2>&1 | tail -5 +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A src/lib/forecastResampling.ts src/lib/forecastValidation.ts tests/unit/forecastResampling.test.ts tests/unit/forecastValidation.test.ts +git commit -m "feat(15-11): drop forecastResampling.ts; slim forecastValidation (D-14)" +``` + +--- + +### Task 2: Refactor `/api/forecast` + +- [ ] **Step 1: Read existing `src/routes/api/forecast/+server.ts` (post-15v1-fix state)** to confirm current shape. + +- [ ] **Step 2: Rewrite the GET handler**: + +```ts +// src/routes/api/forecast/+server.ts (post-15-11 state) +// Phase 15 v2 D-14 / D-15 / D-18. +// Returns native-grain forecasts joined with back-test-window actuals from +// kpi_daily_v. Drops resampling — Phase 14 v2 (15-10) writes rows at the +// native grain (day/week/month), one model run per grain per refresh. +// +// Auth: locals.safeGetSession(). RLS: forecast_with_actual_v (security_invoker) +// + kpi_daily_v (wrapper). Cache-Control: private, no-store. +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { fetchAll } from '$lib/supabasePagination'; +import { parseGranularity, type Granularity } from '$lib/forecastValidation'; +import { clampEvents, type ForecastEvent } from '$lib/forecastEventClamp'; +import { format, subDays, subWeeks, subMonths, startOfWeek, startOfMonth } from 'date-fns'; + +const KPIS = ['revenue_eur', 'invoice_count'] as const; +type Kpi = typeof KPIS[number]; + +type ForecastViewRow = { + target_date: string; + model_name: string; + granularity: Granularity; + yhat: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; + actual_value: number | null; + forecast_track: string; + kpi_name: string; +}; + +type DailyKpiRow = { business_date: string; revenue_cents: number; tx_count: number }; + +const NO_STORE = { 'Cache-Control': 'private, no-store' }; + +function backtestStart(lastActual: Date, grain: Granularity): Date { + if (grain === 'day') return subDays(lastActual, 7); + if (grain === 'week') return startOfWeek(subDays(lastActual, 35), { weekStartsOn: 1 }); + // month: 4 complete months back from current month start, excluding partial current + return startOfMonth(subMonths(lastActual, 4)); +} + +export const GET: RequestHandler = async ({ locals, url }) => { + const { claims } = await locals.safeGetSession(); + if (!claims) return json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE }); + + const granularity = parseGranularity(url.searchParams.get('granularity')); + if (!granularity) return json({ error: 'invalid granularity' }, { status: 400, headers: NO_STORE }); + + const kpiRaw = url.searchParams.get('kpi') ?? 'revenue_eur'; + if (!(KPIS as readonly string[]).includes(kpiRaw)) { + return json({ error: 'invalid kpi' }, { status: 400, headers: NO_STORE }); + } + const kpi = kpiRaw as Kpi; + + try { + const today = new Date(); + // Forecast rows: read all rows at this granularity for this kpi. + // The MV holds latest run per (target_date, model, grain) so no run_date filter needed. + const forecastRows = await fetchAll(() => + locals.supabase + .from('forecast_with_actual_v') + .select('target_date,model_name,granularity,yhat,yhat_lower,yhat_upper,horizon_days,actual_value,forecast_track,kpi_name') + .eq('kpi_name', kpi) + .eq('forecast_track', 'bau') + .eq('granularity', granularity) + .order('target_date', { ascending: true }) + ); + + // Actuals: read kpi_daily_v from backtest start through today. + // For weekly/monthly grain, downstream consumer aggregates these to buckets. + const lastActualDate = forecastRows.length > 0 + ? forecastRows.reduce((mx, r) => + (r.actual_value !== null && r.target_date > mx) ? r.target_date : mx, '0000-01-01') + : format(subDays(today, 1), 'yyyy-MM-dd'); + + const lastActual = new Date(lastActualDate); + const btStart = format(backtestStart(lastActual, granularity), 'yyyy-MM-dd'); + + const actualsRows = await fetchAll(() => + locals.supabase + .from('kpi_daily_v') + .select('business_date,revenue_cents,tx_count') + .gte('business_date', btStart) + .order('business_date', { ascending: true }) + ); + + const actuals = actualsRows.map(r => ({ + date: r.business_date, + value: kpi === 'revenue_eur' ? r.revenue_cents / 100 : r.tx_count + })); + + // Events (re-use existing clampEvents). + // ... (same query as v1, return events: ForecastEvent[]) ... + const events: ForecastEvent[] = []; // placeholder — keep v1 logic for events here + + // last_run timestamp — Phase 14's pipeline_runs latest forecast_. + // ... (keep v1 logic) ... + const last_run: string | null = null; // TODO: pull from pipeline_runs_status_v + + return json({ + rows: forecastRows.map(r => ({ + target_date: r.target_date, + model_name: r.model_name, + yhat_mean: r.yhat, + yhat_lower: r.yhat_lower, + yhat_upper: r.yhat_upper, + horizon_days: r.horizon_days + })), + actuals, + events: clampEvents(events, 50), + last_run, + kpi, + granularity + }, { headers: NO_STORE }); + } catch (err) { + console.error('[/api/forecast]', err); + return json({ error: 'query failed' }, { status: 500, headers: NO_STORE }); + } +}; +``` + +The implementer should preserve the v1 events-array build (4 source tables joined) and the `pipeline_runs_status_v` last_run lookup — those are unchanged conceptually. The "..." placeholders above mark where the v1 logic gets carried over. + +- [ ] **Step 3: Update `tests/unit/apiEndpoints.test.ts` `/api/forecast` block**: + - Drop horizon-clamp tests (no longer apply) + - Drop resampling test (no longer applies) + - Add `?kpi=invoice_count` test → asserts query against `kpi_name=invoice_count` + - Add `?granularity=week` test → asserts query against `granularity=week` filter + - Add actuals test → asserts kpi_daily_v query fires for backtest window + +- [ ] **Step 4: Run tests** + +```bash +npm run test:unit -- apiEndpoints 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/api/forecast/+server.ts tests/unit/apiEndpoints.test.ts +git commit -m "feat(15-11): /api/forecast native-grain query + ?kpi= param + backtest actuals" +``` + +--- + +## Verification + +- [ ] `npm run test:unit -- forecast` → all green +- [ ] `npm run test:guards` → no `_mv` references introduced +- [ ] `git diff HEAD -- src/routes/+page.server.ts` → empty (still purely deferred client fetch) + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-14 native-grain query | Task 2 | +| D-18 ?kpi= param | Task 2 | +| D-15 actuals extended into backtest window | Task 2 | + +**Prerequisite for** 15-12 / 15-13 (calendar overlays consume `?kpi=`), 15-14 / 15-15 (dedicated cards consume `?kpi=`). diff --git a/.planning/phases/15-forecast-backtest-overlay/15-12-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-12-PLAN.md new file mode 100644 index 0000000..ba8217b --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-12-PLAN.md @@ -0,0 +1,167 @@ +# Phase 15-12: `CalendarRevenueCard` Overlay — Forecast Lines + CI Bands on Bars + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Extend `CalendarRevenueCard.svelte` to render per-model forecast LINES (Spline) + low-opacity CI BANDS (Area) on top of the existing visit-seq stacked bars. Add inline `ForecastLegend` chip row. X-axis extends to `last_actual + 365d` with horizontal scroll. Default visible models: `sarimax` + `naive_dow`. + +**Architecture:** Self-fetch via `clientFetch('/api/forecast?kpi=revenue_eur&granularity=...')` keyed off the global `getFilters().grain`. Render inside the existing `` block: bars first (existing), then `` per visible model for CI band, then `` per visible model for line. Switch from band scale to **time scale** for the X-axis to support the extended forward range; bars retain `bandwidth` calculated from `xScale(addDays(d, 1)) - xScale(d)`. + +**Tech Stack:** Svelte 5 runes, LayerChart 2.x (`Chart`/`Bars`/`Area`/`Spline`), date-fns. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/components/CalendarRevenueCard.svelte` | **Modify** | Add forecast overlay logic — clientFetch on grain change, time-scale X with extended domain, bars+area+spline render order, embedded ForecastLegend. | +| `tests/unit/CalendarCards.test.ts` | **Modify** | Add overlay-specific render tests: legend chip row appears, forecast lines render when forecastData present, deselected models removed entirely. | + +--- + +### Task 1: Extend `CalendarRevenueCard.svelte` + +- [ ] **Step 1: Read current `src/lib/components/CalendarRevenueCard.svelte`** (post-squash) to understand the existing visit-seq stacked-bar shape. + +- [ ] **Step 2: Add forecast state + fetch**: + +```svelte + +``` + +- [ ] **Step 3: Inside the existing `` block, ADD overlay markup BELOW the existing `` block**: + +```svelte + + + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-band')} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower / 100} + y1={(r: { yhat_upper: number }) => r.yhat_upper / 100} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-line')} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean / 100} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + +``` + +> **Note on the `/100` divisions** above: bars are in cents (`Math.round(v / 100)` is done in chartData mapping); forecast yhat values from `/api/forecast?kpi=revenue_eur` come in EUROS (per Phase 14 `forecast_daily.yhat`). To overlay correctly we render forecasts in EUR matching the bars' EUR display. **Key constraint**: ensure y-axis domain accommodates BOTH bar tops AND forecast yhat_upper extents. Compute `yDomain` to include `max(bar_total, forecast yhat_upper)`. + +- [ ] **Step 4: Add `` BELOW the chart**: + +```svelte +{#if forecastData && availableModels.length > 0} + +{/if} +``` + +- [ ] **Step 5: Update `xDomain` and `chartData` to extend to `last_actual + 365d`**. + +The existing card uses `bucketRange(w.from, w.to, grain)` to enumerate periods. Extend the upper bound to include forecast horizon. Use a separate `chartXDomain = $derived([from, addDays(today, 365)])` and pass to ``. + +- [ ] **Step 6: Switch X-axis from band scale to time scale**. + +Replace `xScale={scaleBand()}` (current implicit) with `xScale={scaleTime()}`. Bars need explicit width: compute `bandwidth = (xScale(addDays(d, 1)) - xScale(d)) * 0.8` per bar. LayerChart's `` accepts a `bandwidth` prop on time-scale X. + +- [ ] **Step 7: Run tests** + +```bash +npm run test:unit -- CalendarCards 2>&1 | tail -10 +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/components/CalendarRevenueCard.svelte tests/unit/CalendarCards.test.ts +git commit -m "feat(15-12): CalendarRevenueCard forecast overlay (D-15/D-17/D-18)" +``` + +--- + +## Verification + +- [ ] **Localhost gate (D-12 mandatory)**: `npm run dev`; Chrome MCP navigate; verify: + - Bars still render (no regression on existing visit-seq stacking) + - Forecast lines visible for `sarimax` + `naive_dow` (default visible set) + - CI bands visible at low opacity, color-matched to lines + - Toggling Prophet chip → both line + band appear; toggling off → both gone (option B) + - Horizontal scroll into the forecast region works (last_actual + 365d reachable) + - Console clean + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-15 calendar-overlay rendering | Task 1 step 3 | +| D-17 option-B CI rendering (toggle removes both line+band) | Task 1 step 3 + step 4 | +| D-18 dual-KPI parity (this plan = revenue_eur side) | Task 1 step 2 (?kpi=revenue_eur) | + +**Prerequisite for** 15-13 (CalendarCountsCard sister overlay). diff --git a/.planning/phases/15-forecast-backtest-overlay/15-13-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-13-PLAN.md new file mode 100644 index 0000000..d8bea49 --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-13-PLAN.md @@ -0,0 +1,56 @@ +# Phase 15-13: `CalendarCountsCard` Overlay — Sister of 15-12 for `invoice_count` + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Same overlay treatment as 15-12 but on `CalendarCountsCard.svelte`. Reads `/api/forecast?kpi=invoice_count&granularity=...`. Forecasts are integer transaction counts (no `/100` cent conversion). + +**Architecture:** Direct sibling of 15-12. Same patterns: clientFetch, ForecastLegend, time scale, `` + `` overlay. Different only in: kpi param, no cent→EUR conversion (`invoice_count` is already integer count), y-axis tick format (`formatIntShort` instead of `formatEURShort`). + +**Tech Stack:** Same as 15-12. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/components/CalendarCountsCard.svelte` | **Modify** | Add forecast overlay (same logic as 15-12, kpi=invoice_count). | +| `tests/unit/CalendarCards.test.ts` | **Modify** | Add CalendarCountsCard overlay tests parallel to CalendarRevenueCard. | + +--- + +### Task 1: Extend `CalendarCountsCard.svelte` + +- [ ] **Step 1: Copy the 15-12 overlay pattern verbatim** to `CalendarCountsCard.svelte` with these replacements: + - `?kpi=revenue_eur` → `?kpi=invoice_count` + - Y-value mapping: `r.yhat_mean` (no `/100`) for both bar and overlay + - Y-axis tick format: `formatIntShort` from `$lib/format` + +- [ ] **Step 2: Update `tests/unit/CalendarCards.test.ts`** with CalendarCountsCard parallel tests. + +- [ ] **Step 3: Run tests** + +```bash +npm run test:unit -- CalendarCards 2>&1 | tail -10 +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/CalendarCountsCard.svelte tests/unit/CalendarCards.test.ts +git commit -m "feat(15-13): CalendarCountsCard forecast overlay (D-15/D-17/D-18)" +``` + +--- + +## Verification + +- [ ] **Localhost gate**: bars + lines + CI bands visible for `invoice_count`. Toggle behavior matches 15-12. +- [ ] Cross-verify: CalendarRevenueCard and CalendarCountsCard show consistent forecast SHAPES at the same horizon (only the y-axis units differ). + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-18 dual-KPI parity (invoice_count side) | Task 1 | +| D-15 + D-17 inherited from 15-12 | Task 1 | diff --git a/.planning/phases/15-forecast-backtest-overlay/15-14-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-14-PLAN.md new file mode 100644 index 0000000..967aba0 --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-14-PLAN.md @@ -0,0 +1,286 @@ +# Phase 15-14: `RevenueForecastCard` Rewrite — Drop HorizonToggle, Full Range, All-Method CI + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Rewrite `RevenueForecastCard.svelte` to render the FULL range (back-test + forward) at the global GrainToggle's grain — no internal horizon switching. Delete `HorizonToggle.svelte` + its test. CI bands per visible model (option B). Card is cross-check scaffolding; will be retired in 15-17. + +**Architecture:** Strip horizon state + HorizonToggle import + the bindable horizon prop. Drop `forecastValidation`'s `Horizon` import. Read grain from `getFilters().grain`. Same overlay logic as 15-12 (bars are absent on dedicated card — only lines + CI bands + actuals as line). Keep the existing ForecastHoverPopup integration via ``. + +**Tech Stack:** Same as 15-12. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/components/RevenueForecastCard.svelte` | **Rewrite** | Drop horizon prop + HorizonToggle. Read grain from store. Full range render with CI bands. | +| `src/lib/components/HorizonToggle.svelte` | **Delete** | No longer needed. | +| `tests/unit/HorizonToggle.test.ts` | **Delete** | Same. | +| `src/lib/i18n/messages.ts` | **Modify** | Drop the 5 `horizon_*` keys (en + 4 other locales = 25 lines). | +| `tests/unit/RevenueForecastCard.test.ts` | **Modify** | Drop horizon-toggle tests; add full-range render test. | +| `src/routes/+page.svelte` | **Modify** | Remove `bind:horizon` and `bind:granularity` from RevenueForecastCard mount; remove the `forecastHorizon`/`forecastGranularity` $state declarations and the `$effect` that re-fetched on chip change. | + +--- + +### Task 1: Delete HorizonToggle + +- [ ] **Step 1: Delete files** + +```bash +rm src/lib/components/HorizonToggle.svelte tests/unit/HorizonToggle.test.ts +``` + +- [ ] **Step 2: Drop horizon i18n keys** (`horizon_7d`, `horizon_5w`, `horizon_4mo`, `horizon_1yr`, `horizon_selector_aria`) from each of the 5 locale blocks in `src/lib/i18n/messages.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add -A src/lib/components/HorizonToggle.svelte tests/unit/HorizonToggle.test.ts src/lib/i18n/messages.ts +git commit -m "feat(15-14): delete HorizonToggle (D-14 makes it redundant — global GrainToggle drives grain)" +``` + +--- + +### Task 2: Rewrite RevenueForecastCard + +- [ ] **Step 1: Replace `src/lib/components/RevenueForecastCard.svelte`** with the simplified version: + +```svelte + + +
+

{t(page.data.locale, 'forecast_card_title')}

+

{t(page.data.locale, 'forecast_card_description')}

+ + {#if rows.length === 0} + + {:else} +
+ ({ ...r, target_date_d: parseISO(r.target_date) }))} + x="target_date_d" + y="yhat_mean" + xScale={scaleTime()} + yScale={scaleLinear()} + xDomain={xDomain} + yDomain={yDomain} + padding={{ left: 40, bottom: 24, top: 12, right: 8 }} + tooltipContext={{ mode: 'bisect-x', touchEvents: 'auto' }} + > + + + format(d, 'MMM d')} /> + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-band')} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower} + y1={(r: { yhat_upper: number }) => r.yhat_upper} + curve={curveMonotoneX} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-line')} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean} + curve={curveMonotoneX} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + + + {#if actuals.length > 0} + ({ d: parseISO(a.date), v: a.value }))} + x={(p: { d: Date }) => p.d} + y={(p: { v: number }) => p.v} + stroke="#0f172a" + stroke-width={2} + /> + {/if} + + + {#if chartCtx} + chartCtx.xScale(typeof d === 'string' ? parseISO(d) : d)} + height={chartCtx.height} + /> + {/if} + + + + + + {#snippet children({ data })} + {#if data} + + {/if} + {/snippet} + + +
+ + + {/if} +
+``` + +- [ ] **Step 2: Update `tests/unit/RevenueForecastCard.test.ts`** — drop horizon-prop tests; add full-range smoke test. + +- [ ] **Step 3: Update `src/routes/+page.svelte`** — remove `bind:horizon` and `bind:granularity` from RevenueForecastCard mount; remove the `forecastHorizon`/`forecastGranularity` $state and the re-fetch $effect (the rewritten card self-fetches on grain change). + +- [ ] **Step 4: Run tests** + +```bash +npm run test:unit -- RevenueForecastCard 2>&1 | tail -10 +npm run check 2>&1 | tail -5 +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/components/RevenueForecastCard.svelte tests/unit/RevenueForecastCard.test.ts src/routes/+page.svelte +git commit -m "feat(15-14): RevenueForecastCard rewrite — drop HorizonToggle, full range, CI bands per visible model" +``` + +--- + +## Verification + +- [ ] **Localhost gate**: full back-test + forward range visible at default grain (day). Switch global GrainToggle to week → card re-fetches and shows weekly buckets. CI bands visible at low opacity for visible models. + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-14 grain-driven full range (no horizon switch) | Task 2 | +| D-15 calendar-overlay rendering (cross-check version on dedicated card) | Task 2 | +| D-17 option-B CI bands | Task 2 step 1 | + +**Prerequisite for** 15-15 (InvoiceCountForecastCard sibling). diff --git a/.planning/phases/15-forecast-backtest-overlay/15-15-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-15-PLAN.md new file mode 100644 index 0000000..839ade3 --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-15-PLAN.md @@ -0,0 +1,107 @@ +# Phase 15-15: `InvoiceCountForecastCard` — Sibling of RevenueForecastCard for `invoice_count` + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** New `InvoiceCountForecastCard.svelte` mirroring 15-14's `RevenueForecastCard` but for the `invoice_count` KPI. Mounts on `+page.svelte` immediately after `RevenueForecastCard`. Will be retired alongside RevenueForecastCard in 15-17. + +**Architecture:** Direct copy of 15-14 with KPI-specific changes: `?kpi=invoice_count`, `formatIntShort` for y-axis, no `/100` cent conversion (counts are already integer), card title + description i18n keys differ. + +**Tech Stack:** Same as 15-14. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/components/InvoiceCountForecastCard.svelte` | **Create** | Sibling component. | +| `tests/unit/InvoiceCountForecastCard.test.ts` | **Create** | Render + state tests parallel to RevenueForecastCard. | +| `src/lib/i18n/messages.ts` | **Modify** | Add 2 i18n keys × 5 locales: `invoice_forecast_card_title`, `invoice_forecast_card_description`. | +| `src/routes/+page.svelte` | **Modify** | Mount `` directly below ``. | + +--- + +### Task 1: Add i18n keys + +- [ ] **Step 1: Add to all 5 locale blocks**: + +```ts + // --- Invoice count forecast card (Phase 15-15 / D-18) --- + invoice_forecast_card_title: 'Invoice count forecast', + invoice_forecast_card_description: 'Tomorrow through next year — actual transactions vs. forecast.', +``` + +- [ ] **Step 2: Run typecheck** + +```bash +npm run check 2>&1 | tail -3 +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/i18n/messages.ts +git commit -m "feat(15-15): add 2 i18n keys for InvoiceCountForecastCard" +``` + +--- + +### Task 2: Component + +- [ ] **Step 1: Copy `RevenueForecastCard.svelte` to `InvoiceCountForecastCard.svelte`** with these replacements: + - Component data-testid: `invoice-forecast-card` + - i18n: `forecast_card_title` → `invoice_forecast_card_title`, `forecast_card_description` → `invoice_forecast_card_description` + - clientFetch URL: `?kpi=invoice_count` + - Y-axis format: `formatEURShort` → `formatIntShort` + - Drop the `/100` divisions (counts are already integer) + +- [ ] **Step 2: Write `tests/unit/InvoiceCountForecastCard.test.ts`** — copy RevenueForecastCard.test.ts, adjust test-ids and mock data to use `invoice_count`-shaped values. + +- [ ] **Step 3: Run tests** + +```bash +npm run test:unit -- InvoiceCountForecastCard 2>&1 | tail -10 +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/components/InvoiceCountForecastCard.svelte tests/unit/InvoiceCountForecastCard.test.ts +git commit -m "feat(15-15): add InvoiceCountForecastCard sibling (D-18)" +``` + +--- + +### Task 3: Mount on `+page.svelte` + +- [ ] **Step 1: Edit `+page.svelte`** — add `import InvoiceCountForecastCard from '$lib/components/InvoiceCountForecastCard.svelte';` and mount it directly below the RevenueForecastCard LazyMount block: + +```svelte + + {#snippet children()} + + {/snippet} + +``` + +> Since the card self-fetches via `clientFetch`, no extra page-level state is needed. The LazyMount's `onvisible` is just a hook to trigger the IntersectionObserver — the actual load happens inside the card. + +- [ ] **Step 2: Commit** + +```bash +git add src/routes/+page.svelte +git commit -m "feat(15-15): mount InvoiceCountForecastCard on dashboard (D-18)" +``` + +--- + +## Verification + +- [ ] **Localhost gate**: both `RevenueForecastCard` and `InvoiceCountForecastCard` visible on dashboard. Both update on global GrainToggle change. CI bands + line behavior consistent across the two. + +## Spec Coverage + +| Decision | Where covered | +|---|---| +| D-18 dual-KPI parity (invoice side) | Tasks 2-3 | +| D-15 dedicated-card cross-check | Task 2 | diff --git a/.planning/phases/15-forecast-backtest-overlay/15-16-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-16-PLAN.md new file mode 100644 index 0000000..bb9d16b --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-16-PLAN.md @@ -0,0 +1,52 @@ +# Phase 15-16: Localhost Gate + DEV Deploy QA + STATE/ROADMAP Closure + +> REQUIRED SUB-SKILL: superpowers:executing-plans (inline; the Chrome MCP gate is interactive). + +**Goal:** Mandatory localhost-first verification of the full v2 stack on `localhost:5173`, DEV deploy via `gh workflow run deploy.yml --ref feature/phase-15-forecast-backtest-overlay`, cross-check overlays-vs-dedicated-cards parity, then close out STATE.md + ROADMAP.md. + +--- + +## Tasks + +### Task 1: Localhost gate + +- [ ] Start dev server (`npm run dev`). +- [ ] Sign in via Chrome MCP at `http://localhost:5173/login`. +- [ ] Verify on `/`: + - **CalendarRevenueCard**: bars (existing) + forecast lines starting back-test-period before last_actual + CI bands at low opacity. Toggle Prophet → both line+band appear. Toggle off → both gone. + - **CalendarCountsCard**: same overlay shape for invoice_count. + - **RevenueForecastCard** (cross-check): same forecast lines + bands as the calendar overlay's. Numbers should match the bar tops in the back-test region. + - **InvoiceCountForecastCard** (cross-check): same. + - **Global GrainToggle** (Day/Week/Month): all 4 surfaces re-fetch and re-render at the new grain. + - **Console**: zero `invalid_default_snippet`, zero `each_key_duplicate`, zero errors. +- [ ] Take screenshots at each grain × each card type (12 screenshots total). Save to `.planning/phases/15-forecast-backtest-overlay/screenshots/`. + +### Task 2: DEV deploy + +- [ ] `git push -u origin feature/phase-15-forecast-backtest-overlay` +- [ ] `gh workflow run deploy.yml --ref feature/phase-15-forecast-backtest-overlay` +- [ ] `gh run watch --exit-status` +- [ ] Open `https://ramen-bones-analytics.pages.dev` in normal browser. Repeat the visual checks from Task 1. (Chrome MCP may be unstable on long sessions; user-driven check is fine here.) + +### Task 3: Close out planning docs + +- [ ] Update `.planning/STATE.md` frontmatter: + - `progress.completed_phases: 15` + - `progress.total_plans: 81` (was 72; +9 v2 plans) + - `progress.completed_plans: 70` (61 + 9; assumes SUMMARY.md files generated as part of `/gsd-extract-learnings`, see follow-up) + - `last_updated`: today + - `status`: `Phase 15 v2 shipped` +- [ ] `.planning/ROADMAP.md`: tick `[x]` on Phase 15 line; update progress table row `15. Forecast Chart UI` to `9/9 Complete`. +- [ ] Run `.claude/scripts/validate-planning-docs.sh` — must pass. + +### Task 4: Final commit + open PR + +- [ ] Commit STATE/ROADMAP closure. +- [ ] `gh pr create` with title `feat(15v2): Forecast Backtest Overlay — replaces closed PR #25`. +- [ ] Watch CI; if vitest fails on the same pre-existing InsightCard locale-state leak as in PR #25, note it explicitly in the PR body — those failures are not v2's concern. + +--- + +## Spec Coverage + +All FUI-01..FUI-09 closed by Phase 15 v2 (re-mapped from v1's interpretation). D-12 localhost gate satisfied. STATE/ROADMAP drift-gate validator green. diff --git a/.planning/phases/15-forecast-backtest-overlay/15-17-PLAN.md b/.planning/phases/15-forecast-backtest-overlay/15-17-PLAN.md new file mode 100644 index 0000000..4c035dc --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-17-PLAN.md @@ -0,0 +1,39 @@ +# Phase 15-17: Retire Dedicated Forecast Cards (Deferred Cleanup) + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development. +> **Run only AFTER 15-16 visual QA confirms calendar overlays match dedicated cards.** + +**Goal:** Delete `RevenueForecastCard.svelte` + `InvoiceCountForecastCard.svelte` + their tests + their mounts on `+page.svelte` once cross-check confirms the calendar overlays render the same data correctly. Cleanup phase per user spec ("we will delete them after all"). + +--- + +## Files + +| File | Action | +|---|---| +| `src/lib/components/RevenueForecastCard.svelte` | Delete | +| `src/lib/components/InvoiceCountForecastCard.svelte` | Delete | +| `tests/unit/RevenueForecastCard.test.ts` | Delete | +| `tests/unit/InvoiceCountForecastCard.test.ts` | Delete | +| `src/routes/+page.svelte` | Remove imports + 2 LazyMount blocks | +| `src/lib/i18n/messages.ts` | Remove `forecast_card_*`, `invoice_forecast_card_*` keys (and any popup keys not used elsewhere — check via grep) | +| `tests/unit/ForecastHoverPopup.test.ts` | DECISION: keep if hover popup still used by overlays; delete if not. (15-12 / 15-13 don't use ForecastHoverPopup — overlays use the existing CalendarRevenueCard tooltip. So this test may become orphaned. Verify via grep.) | + +--- + +## Tasks + +- [ ] Confirm overlays match dedicated cards on DEV (cross-check screenshots from 15-16 Task 1). +- [ ] Delete the 4 files (2 components + 2 tests). +- [ ] Edit `+page.svelte` — drop the 2 imports and 2 LazyMount blocks. +- [ ] Drop unused i18n keys (and run `npm run check` to confirm no MessageKey references remain). +- [ ] If `ForecastHoverPopup` is no longer used, delete it + test + its 10 i18n keys. +- [ ] Run all tests: `npm run test:unit && npm run check && npm run test:guards`. +- [ ] Commit: `feat(15-17): retire dedicated forecast cards (calendar overlays validated)` +- [ ] Localhost gate: dashboard now shows ONLY calendar overlays for forecasts. No regressions on other cards. + +--- + +## Spec Coverage + +User spec: "keep it for now. add another one for invoice kpi. we will delete them after all." This plan executes the "delete them after all" step. diff --git a/.planning/phases/15-forecast-backtest-overlay/15-CONTEXT.md b/.planning/phases/15-forecast-backtest-overlay/15-CONTEXT.md new file mode 100644 index 0000000..cc3fee7 --- /dev/null +++ b/.planning/phases/15-forecast-backtest-overlay/15-CONTEXT.md @@ -0,0 +1,164 @@ +# Phase 15 v2: Forecast Backtest Overlay — Context + +**Gathered:** 2026-05-01 +**Status:** Ready for planning +**Predecessor:** Phase 15 v1 (closed PR #25) — branch `feature/phase-15-forecast-chart-ui` archived. v1 helpers/endpoints/components/test fixtures all squash-ported in commit `07b6f1f`; subsequent plans incrementally evolve the shape. + +## Why v2 + +Phase 15 v1 shipped a forecast-only forward chart (a dedicated `RevenueForecastCard` with a HorizonToggle), but the real product the owner wants is **backtested forecast lines overlaid on the existing actuals bar charts** so model accuracy can be eyeballed against ground truth. v1 was wrong-product, not wrong-code. + + +## Phase Boundary + +Phase 15 v2 ships forecast LINE + CI BAND overlays on `CalendarRevenueCard` and `CalendarCountsCard` (the existing actuals bar charts), driven by Phase 14 forecast model rows that now train at THREE grain-specific TRAIN_ENDs and store with a `granularity` discriminator. Refresh cadence moves from nightly to weekly Monday morning. Two dedicated forecast cards (`RevenueForecastCard` rewrite + new `InvoiceCountForecastCard`) stay alongside as cross-check surfaces; a deferred 15-17 retires them once the overlays are visually validated. + +**In scope:** + +1. Schema: add `granularity text` to `forecast_daily` PK; rebuild `forecast_daily_mv` + `forecast_with_actual_v` to include it. +2. Backend: `scripts/forecast/run_all.py` runs each model 3× per refresh (daily/weekly/monthly). Cron switches from nightly to `0 7 * * 1` (Monday 07:00 UTC = 09:00 Berlin). +3. `/api/forecast` refactor: drop `forecastResampling.ts`, query by native granularity, add `?kpi=revenue_eur|invoice_count` param, return `actuals` array extending into the back-test window. +4. Calendar overlays: extend `CalendarRevenueCard` + `CalendarCountsCard` with forecast-line splines + low-opacity CI areas (option B per user feedback) + inline ForecastLegend chip row + horizontal-scroll X-axis to last_actual+365d. +5. Dedicated cards (cross-check scaffolding): `RevenueForecastCard` rewrite (drops HorizonToggle), new `InvoiceCountForecastCard` sibling. +6. **15-17 deferred** — retire dedicated cards after cross-check passes. + +**Explicitly out of scope:** + +- New filter surfaces. Granularity is driven by the existing global `GrainToggle` in `FilterBar`. +- Sample-path resampling on the client (forecasts now stored at native grain). +- Track-B counterfactual + `campaign_calendar` (Phase 16). +- Rolling-origin CV backtest, ≥10% RMSE promotion gate (Phase 17). +- Hourly/menu-item forecasts. + + + + +## Implementation Decisions (v2) + +### Carry-forward from v1 (locked, re-stated) + +- **C-01..C-07** — see `feature/phase-15-forecast-chart-ui:.planning/phases/15-forecast-chart-ui/15-CONTEXT.md` (deferred-API + LazyMount, wrapper view only, localhost-first, Tooltip.Root snippet contract, touchEvents 'auto', etc.) +- **D-01** — RevenueForecastCard placement at scroll position 6 (after InsightCard, before KPI tiles). Calendar overlays don't change existing card order; they extend existing cards in place. +- **D-02** — LayerChart `` for CI band rendering (`y0`/`y1` props). +- **D-04** — chip row UX for model toggles. Now embedded INSIDE both calendar cards and the dedicated forecast cards. +- **D-05** — Tooltip.Root + `{#snippet children({ data })}` snippet contract. +- **D-06** — long-format `/api/forecast` payload (rows + actuals + events + last_run). +- **D-07** — `/api/forecast-quality` filtered to `evaluation_window='last_7_days'`. +- **D-08** — `/api/campaign-uplift` hard-coded `CAMPAIGN_START`. Phase 16 generalizes via `campaign_calendar`. +- **D-09** — events folded into `/api/forecast` response. +- **D-10** — categorical 5-color `schemeTableau10` palette. `naive_dow` dashed gray. +- **D-12** — Chrome MCP `localhost:5173` verification gate, mandatory before DEV deploy. +- **D-13** — `touchEvents: 'auto'` default on `` wrapper. + +### NEW v2 decisions + +- **D-14 — Three grain-specific TRAIN_ENDs (REPLACES v1's D-11 horizon clamp matrix).** + + Reference date: assume `last_actual = 2026-04-26` (a Sunday — weekly refresh ingests through previous Sunday). + + | Grain | TRAIN_END | Forecast first target_date | Forecast last target_date | Total horizon | + |---|---|---|---|---| + | Daily | 2026-04-19 (last_actual − 7d) | 2026-04-20 | 2027-04-26 | 372 days | + | Weekly | 2026-03-22 (last_actual − 35d) | 2026-03-23 (Mon ISO week start) | 2027-04-26 | 57 weeks | + | Monthly | 2025-11-30 (end-of-month, 5 calendar months back) | 2025-12-01 | 2027-04-30 | 17 months | + + **Why these specific look-backs:** the back-test window (7d / 5w / 4mo) gives the owner a recent ground-truth comparison — eyeball which model's line tracked actuals best, that's the model to trust forward. + + **Why monthly excludes April 2026:** April is a partial month at refresh time (data through Apr 26 only); training on a partial month would corrupt the model's monthly seasonality. Forecast still draws lines THROUGH April 2026 alongside the partial actual bar. + +- **D-15 — Calendar-overlay rendering (REPLACES v1's standalone-card approach).** + + Forecast lines + CI bands overlaid on the existing `CalendarRevenueCard` (revenue_eur, gross-cents bars) and `CalendarCountsCard` (invoice_count, transaction-count bars). The actuals stay as bars; forecasts render as Spline lines and Area CI bands ABOVE the bars but BELOW the hover guide. X-axis domain extends to `last_actual + 365d` with horizontal scroll on the existing chart wrapper (already supports `overflow-x-auto`). + + The dedicated `RevenueForecastCard` (rewrite) and new `InvoiceCountForecastCard` stay as cross-check surfaces. Deferred 15-17 retires both once overlays visually match. + +- **D-16 — Weekly refresh cadence (REPLACES v1's nightly assumption).** + + `forecast-refresh.yml` cron switches from `0 1 * * *` (nightly 01:00 UTC) → `0 7 * * 1` (Monday 07:00 UTC = 09:00 Berlin). Triggers AFTER Monday's data ingest. Data ingest cron stays as-is. Acceptable lag: forecasts up to ~6 days stale by following Sunday; matches the user's stated weekly review cadence. + +- **D-17 — CI rendering: Option B (all visible-model bands stacked at low opacity).** + + Each visible model renders BOTH its Spline line AND its low-opacity (`fill-opacity={0.06}`) Area CI band. Deselecting a model from the legend removes BOTH the line and its band entirely from the chart. Visual mush risk on 5+ stacked bands acknowledged; user explicitly chose this for cross-comparison clarity. + +- **D-18 — Dual-KPI parity.** + + Both `revenue_eur` and `invoice_count` get full overlay treatment on their respective calendar cards AND a dedicated forecast card. The `/api/forecast` endpoint takes `?kpi=revenue_eur|invoice_count` to switch. Phase 14 already produces forecasts for both KPIs (per migration `0050_forecast_daily.sql`). + +- **D-19 — Partial-month rendering (no badge).** + + When a month is in progress at refresh time (April 2026 in the canonical example), the partial bar renders without a badge. Forecast lines ALSO draw through that partial month. User explicitly: "no badge. users will understand that April is not finished when he reads the chart." + +### Decisions retired from v1 + +- **D-03** ("Today" Rule reference marker) — RETIRED. With back-test + forward chart, the "where actuals end / forecast begins" boundary is implicit in the bar/line transition. Adding a vertical Rule clutters at 375px without adding info. +- **D-11** (horizon × granularity clamp matrix) — REPLACED by D-14. Each grain now has its own pre-computed forecast set; no clamping needed. + +### Dropped surfaces + +- `HorizonToggle.svelte` + test → DELETED in 15-14. Global `GrainToggle` (existing in FilterBar) drives chart granularity. +- `forecastResampling.ts` + test → DELETED in 15-11. Forecasts stored at native grain make resampling unnecessary. + + + + +## Specific implementation pointers + +- **Phase 14 model_name contract** — `'sarimax'`, `'prophet'`, `'ets'`, `'theta'`, `'naive_dow'` (the v1 `sarimax_bau` mistake is already fixed in the squash; carry-forward). +- **CalendarRevenueCard chart wrapper** — already uses `overflow-x-auto` + `computeChartWidth(chartData.length, cardW)` for variable-bar-count rendering. Reuse for the extended X-domain. +- **CalendarCountsCard** — sister card, same shape with `tx_count` instead of `revenue_cents`. +- **Forecast row size at refresh** — 5 BAU models × 3 grains × 2 KPIs × ~400 forecast points (max for daily-grain × 372 days) = ~12,000 rows per restaurant per refresh. Well under any quota. +- **Schema migration backfill safety** — adding NOT NULL `granularity` column with `DEFAULT 'day'`, then dropping default, then bumping NOT NULL. Existing nightly-cron rows backfill cleanly to `'day'`. +- **Cron dependency** — `forecast-refresh.yml` weekly run depends on Monday data ingest having completed. Add a freshness check at the start of `run_all.py`: if `last_actual_date < (today - 8 days)`, abort with status='waiting_for_data'. +- **CalendarCard band scale challenge** — current cards use a band scale (one slot per period). Extending the X domain to last_actual+365d would compress existing bars by 13× at daily grain. Solution: switch to a **time scale** for the overlay region while keeping the band scale's bandwidth for bar widths. Tested approach in v1 RevenueForecastCard. Pattern: `xScale={scaleTime()}`, bars width-locked to a computed `bandwidth = xScale(addDays(d, 1)) - xScale(d)`. + + + + +## Canonical References + +**Driving artifacts**: +- `.planning/ROADMAP.md` "Phase 15: Forecast Chart UI" — 6 success criteria, 9 requirements (FUI-01..FUI-09) +- `.planning/REQUIREMENTS.md` FUI-01..FUI-09 + +**Locked decisions from prior phases (still valid)**: +- `.planning/phases/14-forecasting-engine-bau-track/14-CONTEXT.md` — D-04 (200 sample paths), D-13 (4 metrics), D-14 (evaluation_window discriminator), D-09 (env-var feature flag for model availability) +- `.planning/phases/11-ssr-perf-recovery/11-CONTEXT.md` — D-03 (deferred /api/* + LazyMount + clientFetch) +- `.planning/phases/10-charts/10-CONTEXT.md` — D-11 (LazyMount), D-15 (categorical palette), D-17 (grain clamp) +- `.planning/phases/04-mobile-reader-ui/04-CONTEXT.md` — D-11..D-15 (LayerChart Spline/Axis/Tooltip; touch tooltips) + +**v1 archive (read for reference, NOT for cargo-cult)**: +- `feature/phase-15-forecast-chart-ui:.planning/phases/15-forecast-chart-ui/15-CONTEXT.md` — original CONTEXT +- `feature/phase-15-forecast-chart-ui:.planning/phases/15-forecast-chart-ui/15-0[1-8]-PLAN.md` — original plans + +**Existing patterns to copy**: +- `src/lib/components/CalendarRevenueCard.svelte` — current bar-only chart; will be EXTENDED in 15-12 +- `src/lib/components/CalendarCountsCard.svelte` — sister; EXTENDED in 15-13 +- `src/routes/api/customer-ltv/+server.ts` — canonical deferred endpoint shape +- `src/lib/components/HorizonToggle.svelte` — DELETED in 15-14 (use as reference for the segmented-control pattern only) + +**Memory pointers**: +- `.claude/memory/feedback_svelte5_tooltip_snippet.md` — Tooltip.Root + snippet +- `.claude/memory/feedback_layerchart_mobile_scroll.md` — touchEvents 'auto' +- `.claude/memory/feedback_localhost_first_ui_verify.md` — Chrome MCP localhost gate + +**CI guards (still apply)**: +- `scripts/ci-guards.sh` Guard 1 — no raw `_mv` references in `src/` +- `scripts/ci-guards.sh` Guard 2 — no raw `getSession()` server bypass +- `.claude/hooks/localhost-qa-gate.js` — Stop hook on frontend edits + + + + +## Deferred Items + +- **15-17** — retire dedicated forecast cards once cross-check passes (intra-phase deferral) +- **Phase 16 — ITS Uplift Attribution** (campaign_calendar + Track-B counterfactual) +- **Phase 17 — Backtest Gate & Quality Monitoring** (rolling-origin CV + ConformalIntervals + ≥10% RMSE promotion gate) + + + +--- + +*Phase: 15-forecast-backtest-overlay* +*Context updated: 2026-05-01* +*v1 archive: PR #25 closed, branch feature/phase-15-forecast-chart-ui* diff --git a/scripts/forecast/aggregation.py b/scripts/forecast/aggregation.py new file mode 100644 index 0000000..9e182d4 --- /dev/null +++ b/scripts/forecast/aggregation.py @@ -0,0 +1,28 @@ +# scripts/forecast/aggregation.py +# Phase 15 v2 D-14: bucket daily input into weekly (ISO Mon-start) or +# monthly (first-of-month) for grain-specific model fits. Sum aggregation +# matches the user's mental model: weekly revenue = sum of 7 daily values; +# monthly invoice_count = sum of all in-month transactions. +# +# date_col defaults to 'business_date' (kpi_daily_mv canonical column) but +# can be overridden — model fit scripts internally rename to 'date', so they +# call these helpers with date_col='date'. +import pandas as pd + +def bucket_to_weekly(df: pd.DataFrame, *, value_col: str, date_col: str = 'business_date') -> pd.DataFrame: + """Aggregate `df` (must have a date column) into ISO-week buckets keyed by Monday start.""" + out = df.copy() + # Ensure datetime so .dt accessor works (handles both date and datetime input). + out[date_col] = pd.to_datetime(out[date_col]) + # Floor to ISO-Monday week start. + out['week_start'] = out[date_col] - pd.to_timedelta(out[date_col].dt.weekday, unit='D') + g = out.groupby('week_start', as_index=False)[value_col].sum() + return g + +def bucket_to_monthly(df: pd.DataFrame, *, value_col: str, date_col: str = 'business_date') -> pd.DataFrame: + """Aggregate `df` into calendar-month buckets keyed by first-of-month.""" + out = df.copy() + out[date_col] = pd.to_datetime(out[date_col]) + out['month_start'] = out[date_col].dt.to_period('M').dt.start_time + g = out.groupby('month_start', as_index=False)[value_col].sum() + return g diff --git a/scripts/forecast/closed_days.py b/scripts/forecast/closed_days.py index 593e69a..7335d23 100644 --- a/scripts/forecast/closed_days.py +++ b/scripts/forecast/closed_days.py @@ -1,10 +1,35 @@ -"""Closed-day handling for forecast models (D-01, D-03).""" +"""Closed-day handling for forecast models (D-01, D-03). + +Load-bearing assumption (I-3): dates that are NOT present in shop_calendar +are treated as OPEN. Both helpers below derive their behavior from this +default: + + * ``zero_closed_days`` only zeroes preds for dates that exist in the + calendar AND have ``is_open=False``. Anything not in the calendar + keeps the model's predicted yhat -- i.e. is implicitly "open". + * ``filter_open_days`` only sees rows that the caller has already + LEFT-joined against shop_calendar with the same missing=open default. + +This matters at the 372-day daily forecast horizon: most pred_dates are +absent from shop_calendar (it's only populated for confirmed closures / +known holidays), so the missing=open default is exactly what produces the +smooth forward forecast curve. + +If shop_calendar gains "default closed for unknown" semantics later, this +contract MUST update in lockstep with the consumer logic in *_fit.py and +the SQL building shop_cal sets that feed these helpers. +""" from __future__ import annotations import pandas as pd def zero_closed_days(preds: pd.DataFrame, shop_cal: pd.DataFrame) -> pd.DataFrame: - """Force yhat/yhat_lower/yhat_upper to 0 for closed dates (D-01).""" + """Force yhat/yhat_lower/yhat_upper to 0 for closed dates (D-01). + + Closed = present in ``shop_cal`` with ``is_open=False``. Dates absent + from ``shop_cal`` are left untouched (i.e. treated as open) -- see the + module docstring for why this default matters at long horizons. + """ closed_dates = set(shop_cal.loc[~shop_cal['is_open'], 'date']) mask = preds['target_date'].isin(closed_dates) preds = preds.copy() @@ -15,5 +40,9 @@ def zero_closed_days(preds: pd.DataFrame, shop_cal: pd.DataFrame) -> pd.DataFram def filter_open_days(history: pd.DataFrame) -> pd.DataFrame: - """Filter to open days only for non-exog models (D-03).""" + """Filter to open days only for non-exog models (D-03). + + Assumes ``history.is_open`` was populated by an upstream LEFT JOIN that + treats missing-from-shop_calendar as ``True`` -- see module docstring. + """ return history[history['is_open']].reset_index(drop=True) diff --git a/scripts/forecast/ets_fit.py b/scripts/forecast/ets_fit.py index 19a51b6..4736f4d 100644 --- a/scripts/forecast/ets_fit.py +++ b/scripts/forecast/ets_fit.py @@ -1,22 +1,28 @@ -"""Phase 14: ETS model fit and forecast writer. +"""Phase 14 / 15-10: ETS model fit and forecast writer. Subprocess entry point — run as: python -m scripts.forecast.ets_fit -Reads RESTAURANT_ID, KPI_NAME, RUN_DATE from env vars. -Writes 365 rows to forecast_daily via chunked upsert (100 rows/chunk). +Reads RESTAURANT_ID, KPI_NAME, RUN_DATE, GRANULARITY from env vars. Design decisions: - D-03: Train on open-day-only series (closed days carry structural zeros). + D-03: Daily grain trains on open-day-only series (closed days carry + structural zeros). Weekly/monthly grains aggregate the full daily + series (closed days roll into bucket sums) — open/closed gating + only makes sense at daily resolution. ETS does not support exog regressors. - Closed dates in the forecast window are post-hoc zeroed (D-01). + Closed dates in the daily forecast window are post-hoc zeroed (D-01); + weekly/monthly forecasts skip that step. + +15-10: GRANULARITY env (day|week|month) selects native bucket cadence, +TRAIN_END (D-14), horizon, and seasonal_periods. """ from __future__ import annotations import json import os import sys import traceback -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone import numpy as np import pandas as pd @@ -24,15 +30,23 @@ from scripts.forecast.db import make_client from scripts.forecast.closed_days import zero_closed_days, filter_open_days +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly from scripts.forecast.sample_paths import bootstrap_from_residuals, paths_to_jsonb +from scripts.forecast.grain_helpers import ( + HORIZON_BY_GRAIN, + parse_granularity_env, + pred_dates_for_grain, + train_end_for_grain, +) from scripts.external.pipeline_runs_writer import write_success, write_failure # --- Constants --- N_PATHS = 200 -HORIZON = 365 STEP_NAME = 'forecast_ets' CHUNK_SIZE = 100 -SEASONAL_PERIODS = 7 + +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. +SEASONAL_PERIODS_BY_GRAIN = {'day': 7, 'week': 52, 'month': 12} def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: @@ -103,13 +117,13 @@ def _fetch_shop_calendar(client, *, restaurant_id: str, start_date: date, end_da return df -def _fit_ets(y: np.ndarray) -> object: +def _fit_ets(y: np.ndarray, *, seasonal_periods: int) -> object: """Fit ETSModel with add/add/add components, falling back gracefully. Tries (error='add', trend='add', seasonal='add') first. Falls back to (error='add', trend=None, seasonal='add') if convergence fails. """ - shared_kwargs = dict(seasonal_periods=SEASONAL_PERIODS) + shared_kwargs = dict(seasonal_periods=seasonal_periods) try: model = ETSModel(y, error='add', trend='add', seasonal='add', **shared_kwargs) @@ -131,7 +145,7 @@ def _open_future_dates(shop_cal: pd.DataFrame, pred_dates: list) -> list: return [d for d in pred_dates if d not in set(shop_cal['date']) or d in open_set] -def _build_forecast_rows( +def _build_forecast_rows_daily( *, samples: np.ndarray, open_dates: list, @@ -139,13 +153,12 @@ def _build_forecast_rows( restaurant_id: str, kpi_name: str, run_date: date, -) -> list[dict]: - """Build forecast_daily dicts, mapping open-day samples to calendar dates. - - Closed dates receive yhat=0 (handled later by zero_closed_days). - Open dates use corresponding sample path column. + granularity: str, + seasonal_periods: int, +) -> list: + """Daily-grain row builder: maps open-day samples to calendar dates, + closed dates get yhat=0 (zero_closed_days makes it belt-and-suspenders). """ - # Map open_date -> row index in samples open_date_idx = {d: i for i, d in enumerate(open_dates)} rows = [] @@ -171,16 +184,51 @@ def _build_forecast_rows( 'model_name': 'ets', 'run_date': str(run_date), 'forecast_track': 'bau', + 'granularity': granularity, 'yhat': round(yhat, 4), 'yhat_lower': round(yhat_lower, 4), 'yhat_upper': round(yhat_upper, 4), 'yhat_samples': yhat_samples_json, - 'exog_signature': json.dumps({'model': 'ets', 'seasonal_periods': SEASONAL_PERIODS}), + 'exog_signature': json.dumps({'model': 'ets', 'seasonal_periods': seasonal_periods}), + }) + return rows + + +def _build_forecast_rows_bucket( + *, + samples: np.ndarray, + pred_dates: list, + restaurant_id: str, + kpi_name: str, + run_date: date, + granularity: str, + seasonal_periods: int, +) -> list: + """Weekly/monthly row builder: every bucket gets its sample column.""" + rows = [] + for i, target_date in enumerate(pred_dates): + path_values = samples[i] + yhat = float(np.mean(path_values)) + yhat_lower = float(np.percentile(path_values, 10)) + yhat_upper = float(np.percentile(path_values, 90)) + rows.append({ + 'restaurant_id': restaurant_id, + 'kpi_name': kpi_name, + 'target_date': str(target_date), + 'model_name': 'ets', + 'run_date': str(run_date), + 'forecast_track': 'bau', + 'granularity': granularity, + 'yhat': round(yhat, 4), + 'yhat_lower': round(yhat_lower, 4), + 'yhat_upper': round(yhat_upper, 4), + 'yhat_samples': paths_to_jsonb(samples, i), + 'exog_signature': json.dumps({'model': 'ets', 'seasonal_periods': seasonal_periods}), }) return rows -def _upsert_rows(client, rows: list[dict]) -> int: +def _upsert_rows(client, rows: list) -> int: """Upsert rows in chunks of CHUNK_SIZE. Returns total count inserted/updated.""" total = 0 for start in range(0, len(rows), CHUNK_SIZE): @@ -196,73 +244,124 @@ def fit_and_write( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str = 'day', ) -> int: - """Core logic: fit ETS on open days, generate 200 sample paths, write 365 rows. + """Core logic: fit ETS at the chosen grain, generate sample paths, write rows. Returns the number of rows written to forecast_daily. """ - # 1. Fetch training history + horizon = HORIZON_BY_GRAIN[granularity] + seasonal_periods = SEASONAL_PERIODS_BY_GRAIN[granularity] + + # 1. Fetch training history. history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) + last_actual = history['date'].iloc[-1] + train_end = train_end_for_grain(last_actual, granularity) + print( + f'[ets_fit] grain={granularity} last_actual={last_actual} ' + f'train_end={train_end} horizon={horizon} seasonal_periods={seasonal_periods}' + ) - # 2. Filter to open days only (D-03) - open_history = filter_open_days(history) - if len(open_history) < SEASONAL_PERIODS * 2: - raise RuntimeError( - f'Insufficient open-day history: {len(open_history)} rows (need >= {SEASONAL_PERIODS * 2})' + # 2. Truncate to <= train_end. + history = history[history['date'] <= train_end].reset_index(drop=True) + if history.empty: + raise RuntimeError(f'Empty history after train_end cutoff {train_end}') + + if granularity == 'day': + # 3a. Daily path: filter to open days (D-03), fit on n_open observations, + # then fan back out to all calendar days with closed days at 0. + open_history = filter_open_days(history) + if len(open_history) < seasonal_periods * 2: + raise RuntimeError( + f'Insufficient open-day history: {len(open_history)} rows (need >= {seasonal_periods * 2})' + ) + y = open_history['y'].values + + result = _fit_ets(y, seasonal_periods=seasonal_periods) + print(f'[ets_fit] Fitted ETS for {kpi_name}/day on {len(y)} open-day observations') + + all_pred_dates = pred_dates_for_grain( + run_date=run_date, granularity='day', horizon=horizon, ) - y = open_history['y'].values - - # 3. Fit ETS model - result = _fit_ets(y) - print(f'[ets_fit] Fitted ETS for {kpi_name} on {len(y)} open-day observations') - - # 4. Define prediction window - pred_start = run_date + timedelta(days=1) - pred_end = run_date + timedelta(days=HORIZON) - all_pred_dates = [pred_start + timedelta(days=i) for i in range(HORIZON)] - - # 5. Fetch shop calendar and find open future dates - shop_cal = _fetch_shop_calendar( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - open_future = _open_future_dates(shop_cal, all_pred_dates) - n_open = len(open_future) - if n_open == 0: - raise RuntimeError('No open days in forecast window — check shop_calendar') - - # 6. Generate 200 sample paths via simulate on open days - # simulate(anchor='end') appends n_open steps beyond the fitted end - sim_raw = result.simulate( - nsimulations=n_open, - repetitions=N_PATHS, - anchor='end', - ) - samples = sim_raw.values if hasattr(sim_raw, 'values') else np.asarray(sim_raw) - # Expected shape: (n_open, N_PATHS) - assert samples.shape == (n_open, N_PATHS), f'Unexpected ETS samples shape: {samples.shape}' - - # 7. Build forecast rows (open days use sample paths, others get 0) - rows = _build_forecast_rows( - samples=samples, - open_dates=open_future, - all_pred_dates=all_pred_dates, - restaurant_id=restaurant_id, - kpi_name=kpi_name, - run_date=run_date, - ) - preds_df = pd.DataFrame(rows) - preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + shop_cal = _fetch_shop_calendar( + client, + restaurant_id=restaurant_id, + start_date=all_pred_dates[0], + end_date=all_pred_dates[-1], + ) + open_future = _open_future_dates(shop_cal, all_pred_dates) + n_open = len(open_future) + if n_open == 0: + raise RuntimeError('No open days in forecast window — check shop_calendar') + + sim_raw = result.simulate( + nsimulations=n_open, + repetitions=N_PATHS, + anchor='end', + ) + samples = sim_raw.values if hasattr(sim_raw, 'values') else np.asarray(sim_raw) + assert samples.shape == (n_open, N_PATHS), f'Unexpected ETS samples shape: {samples.shape}' - # 8. Zero closed days post-hoc (belt-and-suspenders) - preds_df = zero_closed_days(preds_df, shop_cal) + rows = _build_forecast_rows_daily( + samples=samples, + open_dates=open_future, + all_pred_dates=all_pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity='day', + seasonal_periods=seasonal_periods, + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + preds_df = zero_closed_days(preds_df, shop_cal) + else: + # 3b. Weekly/monthly: aggregate full daily series (open+closed) and fit. + if granularity == 'week': + agg = bucket_to_weekly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'week_start': 'bucket_start'}) + else: # 'month' + agg = bucket_to_monthly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'month_start': 'bucket_start'}) + if agg.empty: + raise RuntimeError(f'Empty aggregation for grain={granularity}') + + y = agg['y'].astype(float).values + if len(y) < seasonal_periods * 2: + raise RuntimeError( + f'Insufficient {granularity} history: {len(y)} buckets (need >= {seasonal_periods * 2})' + ) - # 9. Restore target_date to str for upsert + result = _fit_ets(y, seasonal_periods=seasonal_periods) + print(f'[ets_fit] Fitted ETS for {kpi_name}/{granularity} on {len(y)} buckets') + + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + sim_raw = result.simulate( + nsimulations=horizon, + repetitions=N_PATHS, + anchor='end', + ) + samples = sim_raw.values if hasattr(sim_raw, 'values') else np.asarray(sim_raw) + assert samples.shape == (horizon, N_PATHS), f'Unexpected ETS samples shape: {samples.shape}' + + rows = _build_forecast_rows_bucket( + samples=samples, + pred_dates=pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + seasonal_periods=seasonal_periods, + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + + # 4. Restore target_date to str for upsert preds_df['target_date'] = preds_df['target_date'].astype(str) - # 10. Chunked upsert + # 5. Chunked upsert final_rows = preds_df.to_dict(orient='records') n = _upsert_rows(client, final_rows) return n @@ -277,13 +376,24 @@ def fit_and_write( if not restaurant_id or not kpi_name or not run_date_str: print('ERROR: RESTAURANT_ID, KPI_NAME, and RUN_DATE env vars are required', file=sys.stderr) sys.exit(1) + try: + granularity = parse_granularity_env(os.environ.get('GRANULARITY')) + except ValueError as e: + print(f'ERROR: {e}', file=sys.stderr) + sys.exit(1) run_date = date.fromisoformat(run_date_str) started_at = datetime.now(timezone.utc) client = make_client() try: - n = fit_and_write(client, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date) + n = fit_and_write( + client, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) write_success( client, step_name=STEP_NAME, @@ -291,7 +401,7 @@ def fit_and_write( row_count=n, restaurant_id=restaurant_id, ) - print(f'[ets_fit] Done: {n} rows written for {kpi_name}') + print(f'[ets_fit] Done: {n} rows written for {kpi_name}/{granularity}') sys.exit(0) except Exception: err_msg = traceback.format_exc() diff --git a/scripts/forecast/grain_helpers.py b/scripts/forecast/grain_helpers.py new file mode 100644 index 0000000..8a7e4bb --- /dev/null +++ b/scripts/forecast/grain_helpers.py @@ -0,0 +1,98 @@ +"""Phase 15-10 D-14: shared grain-aware helpers used by all 5 model fit +scripts and by /api/forecast (Phase 15-11). Single source of truth for +horizon, TRAIN_END computation, and forecast bucket date generation. + +Extracted from per-script copies that were verbatim-identical (code review +I-1). Math is grain-driven, not model-driven, so there is no +parallel-evolution argument for keeping copies. +""" +from __future__ import annotations +from datetime import date, timedelta + +from dateutil.relativedelta import relativedelta + +# 15-10 D-14: per-grain forecast horizons (TRAIN_END -> last forecast bucket). +# Daily : 372d (~53 weeks; one extra week vs 52 for edge coverage). +# Weekly: 57 weeks (52 forward + 5-week back-test alignment window). +# Monthly: 17 months (12 forward + 5-month back-test alignment window). +HORIZON_BY_GRAIN: dict[str, int] = {'day': 372, 'week': 57, 'month': 17} + +GRANULARITIES: tuple[str, ...] = ('day', 'week', 'month') + + +def train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff (D-14). + + Day : last_actual - 7 days (one full week back for back-test). + Week : last_actual - 35 days (5 weeks back so all weekly buckets in + the window are COMPLETE -- no partial trailing week sneaks in). + Month: end-of-month for (last_actual.month - 5 calendar months). + E.g. last_actual=2026-04-26 -> 2025-11-30. + + Note: at weekly/monthly grain the gap between train_end and the first + forecast bucket can be ~35 days / ~5 months -- that gap is intentional. + The look-back is sized so each training bucket is fully complete; we + accept the freshness cost in exchange for unbiased trailing-bucket data. + """ + if granularity == 'day': + return last_actual - timedelta(days=7) + if granularity == 'week': + return last_actual - timedelta(days=35) + if granularity == 'month': + # "end of (last_actual minus 5 calendar months)". + # Step 1: subtract 5 months from last_actual to land somewhere in target month. + # Step 2: roll to the last day of THAT month. + anchor = last_actual - relativedelta(months=5) + first_of_anchor = anchor.replace(day=1) + # End of anchor month = (first of next month) - 1 day. + end_of_anchor = (first_of_anchor + relativedelta(months=1)) - timedelta(days=1) + return end_of_anchor + raise ValueError(f'Unknown granularity: {granularity!r}') + + +def pred_dates_for_grain(*, run_date: date, granularity: str, horizon: int) -> list: + """Build native-cadence target_dates starting one bucket after run_date. + + Day : run_date+1, +2, ... +horizon days. + Week : next ISO Monday strictly after run_date, then +7d steps. + Month: first-of-month strictly after run_date, then +1 month steps. + + The first bucket is always strictly AFTER run_date (i.e. if run_date is + a Monday at week grain, the first returned date is the *following* + Monday, not the same day). + """ + if granularity == 'day': + return [run_date + timedelta(days=i + 1) for i in range(horizon)] + if granularity == 'week': + # ISO Monday of week strictly after run_date. + # weekday(): Mon=0..Sun=6. Days to next Monday = (7 - weekday) % 7, but + # if run_date itself is a Mon we still want NEXT Mon (not same day). + days_to_next_mon = (7 - run_date.weekday()) % 7 + if days_to_next_mon == 0: + days_to_next_mon = 7 + first_mon = run_date + timedelta(days=days_to_next_mon) + return [first_mon + timedelta(days=7 * i) for i in range(horizon)] + if granularity == 'month': + # First-of-month strictly after run_date. + first = (run_date.replace(day=1) + relativedelta(months=1)) + return [(first + relativedelta(months=i)) for i in range(horizon)] + raise ValueError(f'Unknown granularity: {granularity!r}') + + +def parse_granularity_env(env_value: str | None, *, default: str = 'day') -> str: + """Parse and validate a GRANULARITY env-var value. + + None or empty/whitespace-only -> `default`. Set-but-invalid -> ValueError + with the same message format the per-script CLI guard used to print, so + operator-facing error text stays consistent. + """ + if env_value is None: + return default + stripped = env_value.strip() + if not stripped: + return default + if stripped not in HORIZON_BY_GRAIN: + raise ValueError( + f'invalid GRANULARITY {stripped!r}; expected one of {list(HORIZON_BY_GRAIN)}' + ) + return stripped diff --git a/scripts/forecast/naive_dow_fit.py b/scripts/forecast/naive_dow_fit.py index d465d85..2fb964e 100644 --- a/scripts/forecast/naive_dow_fit.py +++ b/scripts/forecast/naive_dow_fit.py @@ -1,23 +1,31 @@ -"""Phase 14: Naive day-of-week baseline model fit and forecast writer. +"""Phase 14 / 15-10: Naive seasonal-mean baseline model fit and forecast writer. Subprocess entry point — run as: python -m scripts.forecast.naive_dow_fit -Reads RESTAURANT_ID, KPI_NAME, RUN_DATE from env vars. -Writes 365 rows to forecast_daily via chunked upsert (100 rows/chunk). +Reads RESTAURANT_ID, KPI_NAME, RUN_DATE, GRANULARITY from env vars. Design decisions: - D-03: Uses open-day-only history. + D-03: Daily grain uses open-day-only history. No external library — pure numpy/pandas. - Point forecast: rolling mean of same-DoW values from history. - 200 sample paths via bootstrap_from_residuals using same-DoW residuals (D-16). + +15-10: model_name stays 'naive_dow' (chart legend strings depend on this +per Phase 15 v1's locked decisions) but the seasonal key swings with +granularity: + day -> day-of-week (Mon..Sun, 7 keys) + week -> ISO week-of-year (1..53, ~52 keys) + month -> month-of-year (1..12, 12 keys) +Point forecast = mean of historical bucket values sharing the seasonal key. +200 sample paths via bootstrap_from_residuals using same-key residuals +(D-16). Daily grain still applies the closed-day post-hoc zero-out; +week/month grains skip it (closed days are summed into bucket totals). """ from __future__ import annotations import json import os import sys import traceback -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone from collections import defaultdict import numpy as np @@ -25,22 +33,43 @@ from scripts.forecast.db import make_client from scripts.forecast.closed_days import zero_closed_days, filter_open_days +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly from scripts.forecast.sample_paths import bootstrap_from_residuals, paths_to_jsonb +from scripts.forecast.grain_helpers import ( + HORIZON_BY_GRAIN, + parse_granularity_env, + pred_dates_for_grain, + train_end_for_grain, +) from scripts.external.pipeline_runs_writer import write_success, write_failure # --- Constants --- N_PATHS = 200 -HORIZON = 365 STEP_NAME = 'forecast_naive_dow' CHUNK_SIZE = 100 +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. -def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: - """Fetch kpi_daily_mv history and shop_calendar is_open for open-day filtering. - kpi_daily_mv has columns: business_date, revenue_cents, tx_count. - is_open comes from shop_calendar (not kpi_daily_mv). +def _seasonal_key(d: date, granularity: str) -> int: + """Return the seasonal grouping key for a date at the given grain. + + day : weekday() (Mon=0..Sun=6) + week : ISO week number (1..53) + month: calendar month (1..12) """ + if granularity == 'day': + return d.weekday() + if granularity == 'week': + # isocalendar() returns (year, week, weekday); we use week. + return d.isocalendar()[1] + if granularity == 'month': + return d.month + raise ValueError(f'Unknown granularity: {granularity!r}') + + +def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: + """Fetch kpi_daily_mv history and shop_calendar is_open for open-day filtering.""" resp = ( client.table('kpi_daily_mv') .select('business_date,revenue_cents,tx_count') @@ -53,14 +82,12 @@ def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame if not rows: raise RuntimeError(f'No history found for restaurant_id={restaurant_id}') df = pd.DataFrame(rows) - # Map actual MV columns to canonical names df.rename(columns={'business_date': 'date'}, inplace=True) df['date'] = pd.to_datetime(df['date']).dt.date df['revenue_eur'] = df['revenue_cents'] / 100.0 df['invoice_count'] = df['tx_count'].astype(float) df = df.sort_values('date').reset_index(drop=True) - # Fetch is_open from shop_calendar (kpi_daily_mv does not have this column) cal_resp = ( client.table('shop_calendar') .select('date,is_open') @@ -77,7 +104,6 @@ def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame cal_lookup = dict(zip(cal_df['date'], cal_df['is_open'])) df['is_open'] = [cal_lookup.get(d, True) for d in df['date']] else: - # Default: assume all days open if no shop_calendar data df['is_open'] = True if kpi_name not in df.columns: @@ -103,29 +129,26 @@ def _fetch_shop_calendar(client, *, restaurant_id: str, start_date: date, end_da return df -def _compute_dow_means(open_history: pd.DataFrame) -> dict: - """Compute the rolling mean y value per day-of-week (0=Mon, 6=Sun). - - Returns dict mapping dow -> mean_y. - """ - # Add weekday column to open history - open_history = open_history.copy() - open_history['dow'] = [d.weekday() for d in open_history['date']] - return open_history.groupby('dow')['y'].mean().to_dict() - - -def _compute_dow_residuals(open_history: pd.DataFrame, dow_means: dict) -> dict: - """Compute per-DoW residuals for bootstrap sample path generation. - - Returns dict mapping dow -> array of residuals. +def _seasonal_means_and_residuals( + *, + bucket_dates: list, + bucket_values: np.ndarray, + granularity: str, +) -> tuple: + """Group bucket values by seasonal key and return (means, residuals). + + means: dict {key -> mean_y} + residuals: dict {key -> array of y - mean_y} """ - open_history = open_history.copy() - open_history['dow'] = [d.weekday() for d in open_history['date']] - dow_residuals: dict = defaultdict(list) - for _, row in open_history.iterrows(): - predicted = dow_means.get(row['dow'], row['y']) - dow_residuals[row['dow']].append(row['y'] - predicted) - return {dow: np.array(resids) for dow, resids in dow_residuals.items()} + keyed = defaultdict(list) + for d, v in zip(bucket_dates, bucket_values): + keyed[_seasonal_key(d, granularity)].append(float(v)) + means = {k: float(np.mean(vs)) for k, vs in keyed.items()} + residuals = { + k: np.array([v - means[k] for v in vs], dtype=float) + for k, vs in keyed.items() + } + return means, residuals def _open_future_dates(shop_cal: pd.DataFrame, pred_dates: list) -> list: @@ -135,7 +158,7 @@ def _open_future_dates(shop_cal: pd.DataFrame, pred_dates: list) -> list: return [d for d in pred_dates if d not in cal_dates or d in open_set] -def _build_forecast_rows( +def _build_forecast_rows_daily( *, samples: np.ndarray, open_dates: list, @@ -143,8 +166,9 @@ def _build_forecast_rows( restaurant_id: str, kpi_name: str, run_date: date, -) -> list[dict]: - """Map open-day samples to calendar forecast rows. Closed dates get yhat=0.""" + granularity: str, +) -> list: + """Daily-grain row builder. Closed dates get yhat=0 (zero_closed_days finalizes).""" open_date_idx = {d: i for i, d in enumerate(open_dates)} rows = [] @@ -169,17 +193,50 @@ def _build_forecast_rows( 'model_name': 'naive_dow', 'run_date': str(run_date), 'forecast_track': 'bau', + 'granularity': granularity, 'yhat': round(yhat, 4), 'yhat_lower': round(yhat_lower, 4), 'yhat_upper': round(yhat_upper, 4), 'yhat_samples': yhat_samples_json, - 'exog_signature': json.dumps({'model': 'naive_dow'}), + 'exog_signature': json.dumps({'model': 'naive_dow', 'granularity': granularity}), + }) + return rows + + +def _build_forecast_rows_bucket( + *, + samples: np.ndarray, + pred_dates: list, + restaurant_id: str, + kpi_name: str, + run_date: date, + granularity: str, +) -> list: + """Weekly/monthly row builder.""" + rows = [] + for i, target_date in enumerate(pred_dates): + path_values = samples[i] + yhat = float(np.mean(path_values)) + yhat_lower = float(np.percentile(path_values, 10)) + yhat_upper = float(np.percentile(path_values, 90)) + rows.append({ + 'restaurant_id': restaurant_id, + 'kpi_name': kpi_name, + 'target_date': str(target_date), + 'model_name': 'naive_dow', + 'run_date': str(run_date), + 'forecast_track': 'bau', + 'granularity': granularity, + 'yhat': round(yhat, 4), + 'yhat_lower': round(yhat_lower, 4), + 'yhat_upper': round(yhat_upper, 4), + 'yhat_samples': paths_to_jsonb(samples, i), + 'exog_signature': json.dumps({'model': 'naive_dow', 'granularity': granularity}), }) return rows -def _upsert_rows(client, rows: list[dict]) -> int: - """Upsert rows in chunks of CHUNK_SIZE. Returns total count inserted/updated.""" +def _upsert_rows(client, rows: list) -> int: total = 0 for start in range(0, len(rows), CHUNK_SIZE): chunk = rows[start:start + CHUNK_SIZE] @@ -194,87 +251,146 @@ def fit_and_write( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str = 'day', ) -> int: - """Core logic: compute DoW means, bootstrap 200 paths, write 365 rows. + """Compute seasonal means at the chosen grain, bootstrap paths, write rows.""" + horizon = HORIZON_BY_GRAIN[granularity] - Returns the number of rows written to forecast_daily. - """ - # 1. Fetch training history + # 1. Fetch training history. history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) + last_actual = history['date'].iloc[-1] + train_end = train_end_for_grain(last_actual, granularity) + print( + f'[naive_dow_fit] grain={granularity} last_actual={last_actual} ' + f'train_end={train_end} horizon={horizon}' + ) + + # 2. Truncate to <= train_end. + history = history[history['date'] <= train_end].reset_index(drop=True) + if history.empty: + raise RuntimeError(f'Empty history after train_end cutoff {train_end}') + + if granularity == 'day': + # 3a. Open-day-only history feeds DoW means. + open_history = filter_open_days(history) + if len(open_history) < 7: + raise RuntimeError( + f'Insufficient open-day history: {len(open_history)} rows (need >= 7)' + ) - # 2. Filter to open days only (D-03) - open_history = filter_open_days(history) - if len(open_history) < 7: - raise RuntimeError( - f'Insufficient open-day history: {len(open_history)} rows (need >= 7)' + bucket_dates = list(open_history['date']) + bucket_values = open_history['y'].values + means, residuals = _seasonal_means_and_residuals( + bucket_dates=bucket_dates, + bucket_values=bucket_values, + granularity='day', ) + print(f'[naive_dow_fit] DoW means computed for {kpi_name}: {means}') - # 3. Compute DoW means and residuals - dow_means = _compute_dow_means(open_history) - dow_residuals = _compute_dow_residuals(open_history, dow_means) - print(f'[naive_dow_fit] DoW means computed for {kpi_name}: {dow_means}') - - # 4. Define prediction window - pred_start = run_date + timedelta(days=1) - pred_end = run_date + timedelta(days=HORIZON) - all_pred_dates = [pred_start + timedelta(days=i) for i in range(HORIZON)] - - # 5. Fetch shop calendar and find open future dates - shop_cal = _fetch_shop_calendar( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - open_future = _open_future_dates(shop_cal, all_pred_dates) - n_open = len(open_future) - if n_open == 0: - raise RuntimeError('No open days in forecast window — check shop_calendar') - - # 6. Compute point forecasts for each open future date - point_forecast = np.array([ - dow_means.get(d.weekday(), np.mean(list(dow_means.values()))) - for d in open_future - ]) - - # 7. Gather all residuals across all DoWs for bootstrap - # Use same-DoW residuals where available, fall back to all-DoW pool - all_residuals = np.concatenate([r for r in dow_residuals.values()]) if dow_residuals else np.array([0.0]) - - # 8. Bootstrap 200 sample paths (D-16) - samples = bootstrap_from_residuals( - point_forecast=point_forecast, - residuals=all_residuals, - n_paths=N_PATHS, - ) - assert samples.shape == (n_open, N_PATHS), f'Unexpected samples shape: {samples.shape}' - - # 9. Build forecast rows - rows = _build_forecast_rows( - samples=samples, - open_dates=open_future, - all_pred_dates=all_pred_dates, - restaurant_id=restaurant_id, - kpi_name=kpi_name, - run_date=run_date, - ) - preds_df = pd.DataFrame(rows) - preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + all_pred_dates = pred_dates_for_grain( + run_date=run_date, granularity='day', horizon=horizon, + ) + shop_cal = _fetch_shop_calendar( + client, + restaurant_id=restaurant_id, + start_date=all_pred_dates[0], + end_date=all_pred_dates[-1], + ) + open_future = _open_future_dates(shop_cal, all_pred_dates) + n_open = len(open_future) + if n_open == 0: + raise RuntimeError('No open days in forecast window — check shop_calendar') + + global_mean = float(np.mean(list(means.values()))) if means else 0.0 + point_forecast = np.array([ + means.get(_seasonal_key(d, 'day'), global_mean) for d in open_future + ]) + all_residuals = np.concatenate(list(residuals.values())) if residuals else np.array([0.0]) + + samples = bootstrap_from_residuals( + point_forecast=point_forecast, + residuals=all_residuals, + n_paths=N_PATHS, + ) + assert samples.shape == (n_open, N_PATHS), f'Unexpected samples shape: {samples.shape}' - # 10. Zero closed days post-hoc (belt-and-suspenders) - preds_df = zero_closed_days(preds_df, shop_cal) + rows = _build_forecast_rows_daily( + samples=samples, + open_dates=open_future, + all_pred_dates=all_pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity='day', + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + preds_df = zero_closed_days(preds_df, shop_cal) + else: + # 3b. Weekly/monthly: aggregate full series, then group by week-of-year + # or month-of-year. + if granularity == 'week': + agg = bucket_to_weekly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'week_start': 'bucket_start'}) + else: # 'month' + agg = bucket_to_monthly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'month_start': 'bucket_start'}) + if agg.empty: + raise RuntimeError(f'Empty aggregation for grain={granularity}') + + # bucket_start is a Timestamp; convert to date for _seasonal_key. + bucket_dates = [pd.Timestamp(b).date() for b in agg['bucket_start']] + bucket_values = agg['y'].astype(float).values + if len(bucket_values) < 2: + raise RuntimeError( + f'Insufficient {granularity} history: {len(bucket_values)} buckets' + ) - # 11. Restore target_date to str for upsert + means, residuals = _seasonal_means_and_residuals( + bucket_dates=bucket_dates, + bucket_values=bucket_values, + granularity=granularity, + ) + print(f'[naive_dow_fit] {granularity} seasonal means for {kpi_name}: {len(means)} keys') + + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + + global_mean = float(np.mean(list(means.values()))) if means else 0.0 + point_forecast = np.array([ + means.get(_seasonal_key(d, granularity), global_mean) for d in pred_dates + ]) + all_residuals = np.concatenate(list(residuals.values())) if residuals else np.array([0.0]) + + samples = bootstrap_from_residuals( + point_forecast=point_forecast, + residuals=all_residuals, + n_paths=N_PATHS, + ) + assert samples.shape == (horizon, N_PATHS), f'Unexpected samples shape: {samples.shape}' + + rows = _build_forecast_rows_bucket( + samples=samples, + pred_dates=pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + + # 4. Restore target_date to str for upsert preds_df['target_date'] = preds_df['target_date'].astype(str) - # 12. Chunked upsert + # 5. Chunked upsert final_rows = preds_df.to_dict(orient='records') n = _upsert_rows(client, final_rows) return n if __name__ == '__main__': - # Read env vars restaurant_id = os.environ.get('RESTAURANT_ID', '').strip() kpi_name = os.environ.get('KPI_NAME', '').strip() run_date_str = os.environ.get('RUN_DATE', '').strip() @@ -282,13 +398,24 @@ def fit_and_write( if not restaurant_id or not kpi_name or not run_date_str: print('ERROR: RESTAURANT_ID, KPI_NAME, and RUN_DATE env vars are required', file=sys.stderr) sys.exit(1) + try: + granularity = parse_granularity_env(os.environ.get('GRANULARITY')) + except ValueError as e: + print(f'ERROR: {e}', file=sys.stderr) + sys.exit(1) run_date = date.fromisoformat(run_date_str) started_at = datetime.now(timezone.utc) client = make_client() try: - n = fit_and_write(client, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date) + n = fit_and_write( + client, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) write_success( client, step_name=STEP_NAME, @@ -296,7 +423,7 @@ def fit_and_write( row_count=n, restaurant_id=restaurant_id, ) - print(f'[naive_dow_fit] Done: {n} rows written for {kpi_name}') + print(f'[naive_dow_fit] Done: {n} rows written for {kpi_name}/{granularity}') sys.exit(0) except Exception: err_msg = traceback.format_exc() diff --git a/scripts/forecast/prophet_fit.py b/scripts/forecast/prophet_fit.py index a6f42cd..b4c6c45 100644 --- a/scripts/forecast/prophet_fit.py +++ b/scripts/forecast/prophet_fit.py @@ -1,19 +1,28 @@ -"""Phase 14: Prophet model fit and forecast writer. +"""Phase 14 / 15-10: Prophet model fit and forecast writer. Subprocess entry point — run as: python -m scripts.forecast.prophet_fit -Reads RESTAURANT_ID, KPI_NAME, RUN_DATE from env vars. -Writes 365 rows to forecast_daily via chunked upsert (100 rows/chunk). +Reads RESTAURANT_ID, KPI_NAME, RUN_DATE, GRANULARITY from env vars. -Constraint C-04: yearly_seasonality MUST be False until history >= 730 days. +15-10 changes: + - GRANULARITY env (day|week|month) selects native bucket cadence. + - Daily path keeps the original Prophet+exog setup (C-04: yearly_seasonality + stays False until 730 days of history). + - Weekly/monthly paths drop exog regressors (exog matrix is daily-shaped; + bucket-aggregating it is out of scope) and tune Prophet's seasonality + flags to the bucket cadence. + +Constraint C-04: yearly_seasonality MUST be False until history >= 730 days +(daily) / 104 weeks / 24 months. Naive guard: count buckets, gate. """ from __future__ import annotations import json import os import sys import traceback -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone +from typing import Optional import numpy as np import pandas as pd @@ -22,15 +31,25 @@ from scripts.forecast.db import make_client from scripts.forecast.exog import build_exog_matrix, EXOG_COLUMNS from scripts.forecast.closed_days import zero_closed_days +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly from scripts.forecast.sample_paths import paths_to_jsonb +from scripts.forecast.grain_helpers import ( + HORIZON_BY_GRAIN, + parse_granularity_env, + pred_dates_for_grain, + train_end_for_grain, +) from scripts.external.pipeline_runs_writer import write_success, write_failure # --- Constants --- N_PATHS = 200 -HORIZON = 365 STEP_NAME = 'forecast_prophet' CHUNK_SIZE = 100 +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. +# Yearly seasonality requires ~2 full cycles; numbers in native buckets. +YEARLY_THRESHOLD_BY_GRAIN = {'day': 730, 'week': 104, 'month': 24} + # Regressor columns — weather_source is metadata, not a numeric regressor _REGRESSOR_COLS = [c for c in EXOG_COLUMNS if c != 'weather_source'] @@ -83,52 +102,57 @@ def _fetch_shop_calendar(client, *, restaurant_id: str, start_date: date, end_da return df -def _build_prophet_df(history: pd.DataFrame, X_fit: pd.DataFrame) -> pd.DataFrame: - """Build Prophet training DataFrame with ds, y, and regressor columns. - - Prophet requires columns named 'ds' (datetime) and 'y' (target). - NaN in y is accepted by Prophet. Regressors must be non-NaN. - """ +def _build_prophet_df(history: pd.DataFrame, X_fit: Optional[pd.DataFrame]) -> pd.DataFrame: + """Build Prophet training DataFrame with ds, y, and (daily-only) regressor columns.""" df = pd.DataFrame({ 'ds': pd.to_datetime(history['date']), 'y': history['y'].values, }) - # Attach regressor columns from exog matrix - X_reset = X_fit.reset_index(drop=True) - for col in _REGRESSOR_COLS: - if col in X_reset.columns: - df[col] = X_reset[col].values + if X_fit is not None: + X_reset = X_fit.reset_index(drop=True) + for col in _REGRESSOR_COLS: + if col in X_reset.columns: + df[col] = X_reset[col].values return df -def _build_future_df(pred_dates: list, X_pred: pd.DataFrame) -> pd.DataFrame: - """Build Prophet future DataFrame with ds and regressor columns. - - NaN in regressor columns is NOT allowed — asserted before use. - """ +def _build_future_df(pred_dates: list, X_pred: Optional[pd.DataFrame]) -> pd.DataFrame: + """Build Prophet future DataFrame with ds and (daily-only) regressor columns.""" future = pd.DataFrame({'ds': pd.to_datetime(pred_dates)}) - X_reset = X_pred.reset_index(drop=True) - for col in _REGRESSOR_COLS: - if col in X_reset.columns: - future[col] = X_reset[col].values + if X_pred is not None: + X_reset = X_pred.reset_index(drop=True) + for col in _REGRESSOR_COLS: + if col in X_reset.columns: + future[col] = X_reset[col].values return future -def _fit_prophet(train_df: pd.DataFrame) -> Prophet: - """Fit Prophet model with weekly seasonality and regressors. - - C-04: yearly_seasonality=False required until history >= 730 days. +def _fit_prophet( + train_df: pd.DataFrame, + *, + granularity: str, + use_regressors: bool, + n_buckets: int, +) -> Prophet: + """Fit Prophet with grain-aware seasonality flags. + + C-04: yearly_seasonality stays False until 2 full yearly cycles of buckets + are present (730d / 104w / 24m). Weekly seasonality is meaningless when + each row IS a week or month bucket. """ + yearly_ok = n_buckets >= YEARLY_THRESHOLD_BY_GRAIN[granularity] + weekly_seasonality = (granularity == 'day') + m = Prophet( - yearly_seasonality=False, # C-04: must stay False for short history - weekly_seasonality=True, + yearly_seasonality=yearly_ok, + weekly_seasonality=weekly_seasonality, daily_seasonality=False, uncertainty_samples=N_PATHS, ) - # Add numeric regressors (exclude weather_source which is a metadata string) - for col in _REGRESSOR_COLS: - if col in train_df.columns: - m.add_regressor(col) + if use_regressors: + for col in _REGRESSOR_COLS: + if col in train_df.columns: + m.add_regressor(col) m.fit(train_df) return m @@ -140,8 +164,9 @@ def _build_forecast_rows( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str, exog_sig: dict, -) -> list[dict]: +) -> list: """Convert Prophet sample paths to forecast_daily row dicts. samples shape: (HORIZON, N_PATHS). @@ -159,6 +184,7 @@ def _build_forecast_rows( 'model_name': 'prophet', 'run_date': str(run_date), 'forecast_track': 'bau', + 'granularity': granularity, 'yhat': round(yhat, 4), 'yhat_lower': round(yhat_lower, 4), 'yhat_upper': round(yhat_upper, 4), @@ -168,7 +194,7 @@ def _build_forecast_rows( return rows -def _upsert_rows(client, rows: list[dict]) -> int: +def _upsert_rows(client, rows: list) -> int: """Upsert rows in chunks of CHUNK_SIZE. Returns total count inserted/updated.""" total = 0 for start in range(0, len(rows), CHUNK_SIZE): @@ -184,84 +210,124 @@ def fit_and_write( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str = 'day', ) -> int: - """Core logic: fit Prophet, generate 200 sample paths, write 365 rows. + """Core logic: fit Prophet at the chosen grain, generate sample paths, write rows. Returns the number of rows written to forecast_daily. """ - # 1. Fetch training history - history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) - fit_start = history['date'].iloc[0] - fit_end = history['date'].iloc[-1] + horizon = HORIZON_BY_GRAIN[granularity] - # 2. Build fit exog matrix - X_fit, exog_sig = build_exog_matrix( - client, - restaurant_id=restaurant_id, - start_date=fit_start, - end_date=fit_end, + # 1. Fetch training history (daily from kpi_daily_mv). + history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) + last_actual = history['date'].iloc[-1] + train_end = train_end_for_grain(last_actual, granularity) + print( + f'[prophet_fit] grain={granularity} last_actual={last_actual} ' + f'train_end={train_end} horizon={horizon}' ) - # Align exog to history dates (kpi_daily_mv may have gaps for zero-tx days) - history_dates = set(history['date']) - X_fit = X_fit.loc[X_fit.index.isin(history_dates)] + # 2. Truncate to <= train_end before bucketing. + history = history[history['date'] <= train_end].reset_index(drop=True) + if history.empty: + raise RuntimeError(f'Empty history after train_end cutoff {train_end}') - # 3. Build Prophet training DataFrame - train_df = _build_prophet_df(history, X_fit) + if granularity == 'day': + # 3a. Daily path keeps exog regressors. + fit_start = history['date'].iloc[0] + fit_end = history['date'].iloc[-1] + X_fit, exog_sig = build_exog_matrix( + client, + restaurant_id=restaurant_id, + start_date=fit_start, + end_date=fit_end, + ) + history_dates = set(history['date']) + X_fit = X_fit.loc[X_fit.index.isin(history_dates)] - # 4. Build prediction range and exog matrix - pred_start = run_date + timedelta(days=1) - pred_end = run_date + timedelta(days=HORIZON) - pred_dates = [pred_start + timedelta(days=i) for i in range(HORIZON)] + train_df = _build_prophet_df(history, X_fit) - X_pred, _ = build_exog_matrix( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity='day', horizon=horizon, + ) + pred_start = pred_dates[0] + pred_end = pred_dates[-1] + X_pred, _ = build_exog_matrix( + client, + restaurant_id=restaurant_id, + start_date=pred_start, + end_date=pred_end, + ) + future_df = _build_future_df(pred_dates, X_pred) + nan_count = future_df[[c for c in _REGRESSOR_COLS if c in future_df.columns]].isna().sum().sum() + assert nan_count == 0, f'NaN in future regressor columns: {nan_count} cells' + + n_buckets = len(history) + m = _fit_prophet(train_df, granularity='day', use_regressors=True, n_buckets=n_buckets) + else: + # 3b. Weekly/monthly: bucket then fit without regressors. + if granularity == 'week': + agg = bucket_to_weekly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'week_start': 'ds'}) + else: # 'month' + agg = bucket_to_monthly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'month_start': 'ds'}) + if agg.empty: + raise RuntimeError(f'Empty aggregation for grain={granularity}') + + train_df = pd.DataFrame({ + 'ds': pd.to_datetime(agg['ds']), + 'y': agg['y'].astype(float).values, + }) - # 5. Build future DataFrame and validate no NaN in regressors - future_df = _build_future_df(pred_dates, X_pred) - nan_count = future_df[[c for c in _REGRESSOR_COLS if c in future_df.columns]].isna().sum().sum() - assert nan_count == 0, f'NaN in future regressor columns: {nan_count} cells' + n_buckets = len(train_df) + if n_buckets < 2: + raise RuntimeError( + f'Insufficient {granularity} history: {n_buckets} buckets' + ) - # 6. Fit Prophet model - m = _fit_prophet(train_df) - print(f'[prophet_fit] Fitted Prophet for {kpi_name}') + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + future_df = _build_future_df(pred_dates, None) + + m = _fit_prophet(train_df, granularity=granularity, use_regressors=False, n_buckets=n_buckets) + exog_sig = {'model': 'prophet', 'granularity': granularity, 'n_buckets': n_buckets} - # 7. Generate 200 sample paths via predictive_samples - # Prophet predictive_samples returns dict {'yhat': ndarray} - # Shape is (n_forecast_dates, n_samples) i.e. (HORIZON, N_PATHS) + print(f'[prophet_fit] Fitted Prophet for {kpi_name}/{granularity}') + + # 4. Generate sample paths via predictive_samples. raw = m.predictive_samples(future_df) - samples = raw['yhat'] # shape: (HORIZON, N_PATHS) — no transpose needed - assert samples.shape == (HORIZON, N_PATHS), f'Unexpected samples shape: {samples.shape}' + samples = raw['yhat'] # shape: (HORIZON, N_PATHS) + assert samples.shape == (horizon, N_PATHS), f'Unexpected samples shape: {samples.shape}' - # 8. Build forecast rows + # 5. Build forecast rows. rows = _build_forecast_rows( samples=samples, pred_dates=pred_dates, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date, + granularity=granularity, exog_sig=exog_sig, ) preds_df = pd.DataFrame(rows) preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date - # 9. Fetch shop calendar and zero closed days post-hoc - shop_cal = _fetch_shop_calendar( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - preds_df = zero_closed_days(preds_df, shop_cal) + # 6. Closed-day post-hoc zeroing only at daily grain. + if granularity == 'day': + shop_cal = _fetch_shop_calendar( + client, + restaurant_id=restaurant_id, + start_date=pred_dates[0], + end_date=pred_dates[-1], + ) + preds_df = zero_closed_days(preds_df, shop_cal) - # 10. Restore target_date to str for upsert + # 7. Restore target_date to str for upsert preds_df['target_date'] = preds_df['target_date'].astype(str) - # 11. Chunked upsert + # 8. Chunked upsert final_rows = preds_df.to_dict(orient='records') n = _upsert_rows(client, final_rows) return n @@ -276,13 +342,24 @@ def fit_and_write( if not restaurant_id or not kpi_name or not run_date_str: print('ERROR: RESTAURANT_ID, KPI_NAME, and RUN_DATE env vars are required', file=sys.stderr) sys.exit(1) + try: + granularity = parse_granularity_env(os.environ.get('GRANULARITY')) + except ValueError as e: + print(f'ERROR: {e}', file=sys.stderr) + sys.exit(1) run_date = date.fromisoformat(run_date_str) started_at = datetime.now(timezone.utc) client = make_client() try: - n = fit_and_write(client, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date) + n = fit_and_write( + client, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) write_success( client, step_name=STEP_NAME, @@ -290,7 +367,7 @@ def fit_and_write( row_count=n, restaurant_id=restaurant_id, ) - print(f'[prophet_fit] Done: {n} rows written for {kpi_name}') + print(f'[prophet_fit] Done: {n} rows written for {kpi_name}/{granularity}') sys.exit(0) except Exception: err_msg = traceback.format_exc() diff --git a/scripts/forecast/run_all.py b/scripts/forecast/run_all.py index 0ff6d72..42a9467 100644 --- a/scripts/forecast/run_all.py +++ b/scripts/forecast/run_all.py @@ -1,12 +1,17 @@ -"""Phase 14: run_all.py — nightly forecast pipeline orchestrator. +"""Phase 14 / 15-10: run_all.py — nightly forecast pipeline orchestrator. Spawns each model as a subprocess (autoplan E2), threading Supabase credentials -explicitly into subprocess env (autoplan E7). Iterates models x KPIs. +explicitly into subprocess env (autoplan E7). Iterates models x KPIs x granularities. + +15-10 changes: + - Triple-nested loop adds GRANULARITY (day/week/month) per (model, KPI). + - GRANULARITY env var threads native bucket cadence into each *_fit subprocess. + - Freshness gate (D-16): abort cleanly if last_actual_date is stale (>8 days). After all models: calls refresh_forecast_mvs() RPC. Exit codes: - 0 — at least one model/KPI combo succeeded + 0 — at least one model/KPI/grain combo succeeded, OR clean abort on stale data 1 — all combos failed OR weather_daily guard tripped CLI: @@ -24,9 +29,16 @@ from scripts.forecast.db import make_client from scripts.forecast.last_7_eval import evaluate_last_7 +from scripts.external.pipeline_runs_writer import write_failure DEFAULT_MODELS = 'sarimax,prophet,ets,theta,naive_dow' KPIS = ['revenue_eur', 'invoice_count'] +# 15-10: each model fits at 3 grains per refresh per KPI. +GRANULARITIES = ['day', 'week', 'month'] +# Freshness gate threshold (D-16): if last_actual is stale by more than this, +# abort run_all cleanly instead of fitting on stale data. +FRESHNESS_GATE_DAYS = 8 +STEP_NAME = 'forecast_run_all' def _check_weather_guard(client) -> int: @@ -44,15 +56,51 @@ def _get_restaurant_id(client) -> str: return rows[0]['id'] -def _build_subprocess_env(*, restaurant_id: str, kpi_name: str, run_date: str) -> dict: +def _get_last_actual_date(client, *, restaurant_id: str) -> Optional[date]: + """Return max(business_date) from kpi_daily_mv for this restaurant, or None if empty. + + Used by the freshness gate to abort cleanly when extractor is behind. + """ + resp = ( + client.table('kpi_daily_mv') + .select('business_date') + .eq('restaurant_id', restaurant_id) + .order('business_date', desc=True) + .limit(1) + .execute() + ) + rows = resp.data or [] + if not rows: + return None + raw = rows[0]['business_date'] + # Supabase returns ISO date strings; coerce to date. + if isinstance(raw, str): + return date.fromisoformat(raw[:10]) + if isinstance(raw, datetime): + return raw.date() + if isinstance(raw, date): + return raw + raise RuntimeError(f'Unexpected business_date type from kpi_daily_mv: {type(raw)!r}') + + +def _build_subprocess_env( + *, + restaurant_id: str, + kpi_name: str, + run_date: str, + granularity: str, +) -> dict: """Build env dict for subprocess: inherits current env + injects required vars. Explicitly threads SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (autoplan E7). + 15-10: also threads GRANULARITY so each *_fit picks the matching TRAIN_END, + horizon, seasonal period, and aggregation step. """ env = os.environ.copy() env['RESTAURANT_ID'] = restaurant_id env['KPI_NAME'] = kpi_name env['RUN_DATE'] = run_date + env['GRANULARITY'] = granularity # Ensure Supabase credentials are present (E7: explicit threading, not implicit inheritance) for key in ('SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'): if key not in env: @@ -62,25 +110,36 @@ def _build_subprocess_env(*, restaurant_id: str, kpi_name: str, run_date: str) - return env -def _run_model(*, model: str, restaurant_id: str, kpi_name: str, run_date: str) -> bool: +def _run_model( + *, + model: str, + restaurant_id: str, + kpi_name: str, + run_date: str, + granularity: str, +) -> bool: """Spawn a single model fit as a subprocess. Returns True on success (exit 0).""" env = _build_subprocess_env( restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date, + granularity=granularity, ) cmd = [sys.executable, '-m', f'scripts.forecast.{model}_fit'] - print(f'[run_all] Spawning: {" ".join(cmd)} KPI={kpi_name}') + print(f'[run_all] Spawning: {" ".join(cmd)} KPI={kpi_name} GRAIN={granularity}') result = subprocess.run(cmd, env=env, text=True, capture_output=True) if result.stdout: print(result.stdout, end='') if result.stderr: print(result.stderr, end='', file=sys.stderr) if result.returncode == 0: - print(f'[run_all] {model}/{kpi_name}: SUCCESS') + print(f'[run_all] {model}/{kpi_name}/{granularity}: SUCCESS') return True else: - print(f'[run_all] {model}/{kpi_name}: FAILED (exit {result.returncode})', file=sys.stderr) + print( + f'[run_all] {model}/{kpi_name}/{granularity}: FAILED (exit {result.returncode})', + file=sys.stderr, + ) return False @@ -116,6 +175,45 @@ def main( restaurant_id = _get_restaurant_id(client) print(f'[run_all] restaurant_id: {restaurant_id}') + # 15-10 freshness gate (D-16): if extractor is behind, abort cleanly. + # Writes a pipeline_runs failure row for triage but exits 0 — the workflow + # itself shouldn't fail when upstream data is just late. + last_actual = _get_last_actual_date(client, restaurant_id=restaurant_id) + if last_actual is None: + msg = 'kpi_daily_mv has no rows for restaurant — extractor never ran?' + print(f'[run_all] ABORT: {msg}', file=sys.stderr) + try: + write_failure( + client, + step_name=STEP_NAME, + started_at=datetime.now(timezone.utc), + error_msg=msg, + restaurant_id=restaurant_id, + ) + except Exception as e: + print(f'[run_all] could not write failure row: {e}', file=sys.stderr) + return 0 + days_since_last = (date.today() - last_actual).days + if days_since_last > FRESHNESS_GATE_DAYS: + # D-16 freshness gate: clean abort (return 0), not pipeline failure. + # pipeline_runs_writer only exposes success|fallback|failure; we use + # write_failure here for triage signal but the workflow exit is 0 so + # GHA stays green. Filter pipeline_runs by step_name='forecast_run_all' + # + error_msg starting with 'Stale data' to find these cases. + msg = f'Stale data: last_actual={last_actual} stale by {days_since_last}d' + print(f'[run_all] ABORT: {msg}', file=sys.stderr) + try: + write_failure( + client, + step_name=STEP_NAME, + started_at=datetime.now(timezone.utc), + error_msg=msg, + restaurant_id=restaurant_id, + ) + except Exception as e: + print(f'[run_all] could not write failure row: {e}', file=sys.stderr) + return 0 + # Resolve models list if not models: env_models = os.environ.get('FORECAST_ENABLED_MODELS', DEFAULT_MODELS) @@ -126,27 +224,36 @@ def main( run_date = date.today() - timedelta(days=1) run_date_str = run_date.isoformat() - print(f'[run_all] models={models} kpis={KPIS} run_date={run_date_str}') + print( + f'[run_all] models={models} kpis={KPIS} grains={GRANULARITIES} ' + f'run_date={run_date_str} last_actual={last_actual}' + ) - # Iterate models x KPIs, spawning each as a subprocess + # Iterate models x KPIs x granularities, spawning each as a subprocess. + # 15-10: 5 models × 2 KPIs × 3 grains = 30 spawns/refresh on the full pipeline. successes = 0 total = 0 for model in models: for kpi in KPIS: - total += 1 - ok = _run_model( - model=model, - restaurant_id=restaurant_id, - kpi_name=kpi, - run_date=run_date_str, - ) - if ok: - successes += 1 - - print(f'[run_all] Completed: {successes}/{total} model/KPI combos succeeded') + for granularity in GRANULARITIES: + total += 1 + ok = _run_model( + model=model, + restaurant_id=restaurant_id, + kpi_name=kpi, + run_date=run_date_str, + granularity=granularity, + ) + if ok: + successes += 1 + + print(f'[run_all] Completed: {successes}/{total} model/KPI/grain combos succeeded') # Evaluate last-7-day forecast accuracy for each model/KPI - # Populates forecast_quality table for accuracy tracking + # Populates forecast_quality table for accuracy tracking. + # NOTE: eval still runs at daily grain only — week/month grain accuracy + # tracking is out of scope for 15-10. TODO: Phase 17 (backtest gate) is + # the planned home for grain-specific evaluation windows. if successes > 0: print('[run_all] Running last-7-day evaluation ...') for model in models: diff --git a/scripts/forecast/sarimax_fit.py b/scripts/forecast/sarimax_fit.py index 4a5d06b..a047275 100644 --- a/scripts/forecast/sarimax_fit.py +++ b/scripts/forecast/sarimax_fit.py @@ -1,21 +1,28 @@ -"""Phase 14: SARIMAX model fit and forecast writer. +"""Phase 14 / 15-10: SARIMAX model fit and forecast writer. Subprocess entry point — run as: python -m scripts.forecast.sarimax_fit -Reads RESTAURANT_ID, KPI_NAME, RUN_DATE from env vars. -Writes 365 rows to forecast_daily via chunked upsert (100 rows/chunk). +Reads RESTAURANT_ID, KPI_NAME, RUN_DATE, GRANULARITY from env vars. -Order strategy (autoplan E6): - Primary: SARIMAX(1,0,1)(1,1,1,7) - Fallback: SARIMAX(1,0,1)(0,1,0,7) — used on LinAlgError or NaN params +15-10 changes: + - GRANULARITY env (day|week|month) selects native bucket cadence. + - TRAIN_END computed per grain so each native horizon ends at the same + real-world date target (D-14). + - Horizon, seasonal period, and aggregation step all swing with grain. + - Closed-days post-hoc zeroing only applies at daily grain. + +Order strategy (autoplan E6) per grain: + Daily: SARIMAX(1,0,1)(1,1,1,7) fallback (0,1,0,7) + Weekly: SARIMAX(1,0,1)(1,1,1,52) fallback (0,1,0,52) + Monthly: SARIMAX(1,0,1)(1,1,1,12) fallback (0,1,0,12) """ from __future__ import annotations import json import os import sys import traceback -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone from typing import Optional import numpy as np @@ -26,18 +33,39 @@ from scripts.forecast.db import make_client from scripts.forecast.exog import build_exog_matrix, assert_exog_compatible, EXOG_COLUMNS from scripts.forecast.closed_days import zero_closed_days +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly from scripts.forecast.sample_paths import paths_to_jsonb +from scripts.forecast.grain_helpers import ( + HORIZON_BY_GRAIN, + parse_granularity_env, + pred_dates_for_grain, + train_end_for_grain, +) from scripts.external.pipeline_runs_writer import write_success, write_failure # --- Constants --- PRIMARY_ORDER = (1, 0, 1) -PRIMARY_SEASONAL = (1, 1, 1, 7) -FALLBACK_SEASONAL = (0, 1, 0, 7) N_PATHS = 200 -HORIZON = 365 STEP_NAME = 'forecast_sarimax' CHUNK_SIZE = 100 +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. +SEASONAL_PERIOD_BY_GRAIN = {'day': 7, 'week': 52, 'month': 12} + + +def _seasonal_orders(granularity: str) -> tuple: + """Return (primary, fallback) seasonal_order tuples for the given grain. + + Note: order kept identical (1,1,1)/(0,1,0) across all grains per D-14 + escalation note — only the seasonal *period* swings (7/52/12). At month + grain with ~24 monthly observations needed before seasonal_period*2 fires, + SARIMAX(1,1,1)(1,1,1,12) is on the edge of over-parameterization for new + restaurants. Phase 17 backtest gate is expected to revisit this if + weekly/monthly residuals show clear under/over-fit. + """ + period = SEASONAL_PERIOD_BY_GRAIN[granularity] + return (1, 1, 1, period), (0, 1, 0, period) + def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: """Fetch kpi_daily_mv history for the given restaurant and KPI. @@ -96,11 +124,13 @@ def _drop_metadata_cols(X: pd.DataFrame) -> pd.DataFrame: return X.drop(columns=drop_cols) -def _fit_sarimax(y: np.ndarray, X_fit: pd.DataFrame) -> tuple: +def _fit_sarimax(y: np.ndarray, X_fit: Optional[pd.DataFrame], granularity: str) -> tuple: """Fit SARIMAX with primary order, falling back on LinAlgError or NaN params. - Returns (result, order_used) where order_used is PRIMARY_SEASONAL or FALLBACK_SEASONAL. + Returns (result, order_used) where order_used is the seasonal_order tuple + actually picked. X_fit may be None for non-daily grains (no exog at week/month). """ + primary_seasonal, fallback_seasonal = _seasonal_orders(granularity) # Shared model kwargs model_kwargs = dict( exog=X_fit, @@ -112,20 +142,20 @@ def _fit_sarimax(y: np.ndarray, X_fit: pd.DataFrame) -> tuple: # Try primary seasonal order first try: - model = sm.tsa.SARIMAX(y, seasonal_order=PRIMARY_SEASONAL, **model_kwargs) + model = sm.tsa.SARIMAX(y, seasonal_order=primary_seasonal, **model_kwargs) result = model.fit(**fit_kwargs) if np.isnan(result.params).any(): raise ValueError('NaN params in primary SARIMAX fit') - return result, PRIMARY_SEASONAL + return result, primary_seasonal except (LinAlgError, ValueError) as primary_err: - print(f'[sarimax_fit] Primary order {PRIMARY_SEASONAL} failed: {primary_err!r}; trying fallback {FALLBACK_SEASONAL}') + print(f'[sarimax_fit] Primary order {primary_seasonal} failed: {primary_err!r}; trying fallback {fallback_seasonal}') # Fallback to simpler seasonal order - model = sm.tsa.SARIMAX(y, seasonal_order=FALLBACK_SEASONAL, **model_kwargs) + model = sm.tsa.SARIMAX(y, seasonal_order=fallback_seasonal, **model_kwargs) result = model.fit(**fit_kwargs) if np.isnan(result.params).any(): raise RuntimeError('NaN params in fallback SARIMAX fit — cannot produce forecast') - return result, FALLBACK_SEASONAL + return result, fallback_seasonal def _build_forecast_rows( @@ -135,9 +165,10 @@ def _build_forecast_rows( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str, exog_sig: dict, model_name: str = 'sarimax', -) -> list[dict]: +) -> list: """Convert sample paths to forecast_daily row dicts. samples must be a numpy ndarray of shape (HORIZON, N_PATHS). @@ -157,6 +188,7 @@ def _build_forecast_rows( 'model_name': model_name, 'run_date': str(run_date), 'forecast_track': 'bau', + 'granularity': granularity, 'yhat': round(yhat, 4), 'yhat_lower': round(yhat_lower, 4), 'yhat_upper': round(yhat_upper, 4), @@ -166,7 +198,7 @@ def _build_forecast_rows( return rows -def _upsert_rows(client, rows: list[dict]) -> int: +def _upsert_rows(client, rows: list) -> int: """Upsert rows in chunks of CHUNK_SIZE. Returns total count inserted/updated.""" total = 0 for start in range(0, len(rows), CHUNK_SIZE): @@ -182,86 +214,133 @@ def fit_and_write( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str = 'day', ) -> int: - """Core logic: fit SARIMAX, generate 200 sample paths, write 365 rows. + """Core logic: fit SARIMAX, generate sample paths, write rows. Returns the number of rows written to forecast_daily. """ - # 1. Fetch training history - history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) - fit_start = history['date'].iloc[0] - fit_end = history['date'].iloc[-1] - y = history['y'].values + horizon = HORIZON_BY_GRAIN[granularity] - # 2. Build fit exog matrix - X_fit_raw, exog_sig = build_exog_matrix( - client, - restaurant_id=restaurant_id, - start_date=fit_start, - end_date=fit_end, - ) - X_fit = _drop_metadata_cols(X_fit_raw) - # Align exog to history dates (kpi_daily_mv may have gaps for zero-tx days) - history_dates = set(history['date']) - X_fit = X_fit.loc[X_fit.index.isin(history_dates)] - - # 3. Build prediction exog matrix (run_date+1 through run_date+HORIZON) - pred_start = run_date + timedelta(days=1) - pred_end = run_date + timedelta(days=HORIZON) - pred_dates = [pred_start + timedelta(days=i) for i in range(HORIZON)] - - X_pred_raw, _ = build_exog_matrix( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - X_pred = _drop_metadata_cols(X_pred_raw) - - # 4. Validate column compatibility (autoplan E1) - assert_exog_compatible(X_fit, X_pred) - - # 5. Fit SARIMAX with fallback (autoplan E6) - result, seasonal_used = _fit_sarimax(y, X_fit) - print(f'[sarimax_fit] Fitted SARIMAX{PRIMARY_ORDER}x{seasonal_used} for {kpi_name}') - - # 6. Generate 200 sample paths - # statsmodels simulate() returns a DataFrame; convert to numpy for consistent indexing - samples_raw = result.simulate( - nsimulations=HORIZON, - repetitions=N_PATHS, - anchor='end', - exog=X_pred, + # 1. Fetch training history (always daily from kpi_daily_mv). + history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) + last_actual = history['date'].iloc[-1] + train_end = train_end_for_grain(last_actual, granularity) + print( + f'[sarimax_fit] grain={granularity} last_actual={last_actual} ' + f'train_end={train_end} horizon={horizon}' ) + + # 2. Reduce to <= train_end (daily) BEFORE bucketing for week/month grains. + history = history[history['date'] <= train_end].reset_index(drop=True) + if history.empty: + raise RuntimeError(f'Empty history after train_end cutoff {train_end}') + + if granularity == 'day': + # 3a. Daily path keeps exog regressors and closed-day zeroing. + fit_start = history['date'].iloc[0] + fit_end = history['date'].iloc[-1] + y = history['y'].values + + X_fit_raw, exog_sig = build_exog_matrix( + client, + restaurant_id=restaurant_id, + start_date=fit_start, + end_date=fit_end, + ) + X_fit = _drop_metadata_cols(X_fit_raw) + # Align exog to history dates (kpi_daily_mv may have gaps for zero-tx days) + history_dates = set(history['date']) + X_fit = X_fit.loc[X_fit.index.isin(history_dates)] + + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity='day', horizon=horizon, + ) + pred_start = pred_dates[0] + pred_end = pred_dates[-1] + X_pred_raw, _ = build_exog_matrix( + client, + restaurant_id=restaurant_id, + start_date=pred_start, + end_date=pred_end, + ) + X_pred = _drop_metadata_cols(X_pred_raw) + assert_exog_compatible(X_fit, X_pred) + + result, seasonal_used = _fit_sarimax(y, X_fit, granularity) + print(f'[sarimax_fit] Fitted SARIMAX{PRIMARY_ORDER}x{seasonal_used} for {kpi_name}/{granularity}') + + samples_raw = result.simulate( + nsimulations=horizon, + repetitions=N_PATHS, + anchor='end', + exog=X_pred, + ) + else: + # 3b. Weekly/monthly: aggregate first, no exog (SARIMAX exog at higher + # grain mixes apples/oranges since most exog signals are calendar-day-level). + if granularity == 'week': + agg = bucket_to_weekly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'week_start': 'bucket_start'}) + else: # 'month' + agg = bucket_to_monthly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'month_start': 'bucket_start'}) + + if agg.empty: + raise RuntimeError(f'Empty aggregation for grain={granularity}') + + y = agg['y'].astype(float).values + # Need at least 2 full seasonal cycles to fit. + period = SEASONAL_PERIOD_BY_GRAIN[granularity] + if len(y) < period * 2: + raise RuntimeError( + f'Insufficient {granularity} history: {len(y)} buckets (need >= {period * 2})' + ) + + result, seasonal_used = _fit_sarimax(y, None, granularity) + print(f'[sarimax_fit] Fitted SARIMAX{PRIMARY_ORDER}x{seasonal_used} for {kpi_name}/{granularity}') + + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + samples_raw = result.simulate( + nsimulations=horizon, + repetitions=N_PATHS, + anchor='end', + ) + exog_sig = {'model': 'sarimax', 'granularity': granularity, 'seasonal_period': period} + samples = samples_raw.values if hasattr(samples_raw, 'values') else np.asarray(samples_raw) - # Expected shape: (nsimulations, repetitions) i.e. (HORIZON, N_PATHS) - assert samples.shape == (HORIZON, N_PATHS), f'Unexpected samples shape: {samples.shape}' + # Expected shape: (nsimulations, repetitions) i.e. (horizon, N_PATHS) + assert samples.shape == (horizon, N_PATHS), f'Unexpected samples shape: {samples.shape}' - # 7. Build forecast rows + # 4. Build forecast rows rows = _build_forecast_rows( samples=samples, pred_dates=pred_dates, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date, + granularity=granularity, exog_sig=exog_sig, ) preds_df = pd.DataFrame(rows) preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date - # 8. Fetch shop calendar and zero closed days post-hoc - shop_cal = _fetch_shop_calendar( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - preds_df = zero_closed_days(preds_df, shop_cal) + # 5. Closed-day post-hoc zeroing only applies at daily grain. + if granularity == 'day': + shop_cal = _fetch_shop_calendar( + client, + restaurant_id=restaurant_id, + start_date=pred_dates[0], + end_date=pred_dates[-1], + ) + preds_df = zero_closed_days(preds_df, shop_cal) - # 9. Restore target_date to str for upsert + # 6. Restore target_date to str for upsert preds_df['target_date'] = preds_df['target_date'].astype(str) - # 10. Chunked upsert + # 7. Chunked upsert final_rows = preds_df.to_dict(orient='records') n = _upsert_rows(client, final_rows) return n @@ -276,13 +355,24 @@ def fit_and_write( if not restaurant_id or not kpi_name or not run_date_str: print('ERROR: RESTAURANT_ID, KPI_NAME, and RUN_DATE env vars are required', file=sys.stderr) sys.exit(1) + try: + granularity = parse_granularity_env(os.environ.get('GRANULARITY')) + except ValueError as e: + print(f'ERROR: {e}', file=sys.stderr) + sys.exit(1) run_date = date.fromisoformat(run_date_str) started_at = datetime.now(timezone.utc) client = make_client() try: - n = fit_and_write(client, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date) + n = fit_and_write( + client, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) write_success( client, step_name=STEP_NAME, @@ -290,7 +380,7 @@ def fit_and_write( row_count=n, restaurant_id=restaurant_id, ) - print(f'[sarimax_fit] Done: {n} rows written for {kpi_name}') + print(f'[sarimax_fit] Done: {n} rows written for {kpi_name}/{granularity}') sys.exit(0) except Exception: err_msg = traceback.format_exc() diff --git a/scripts/forecast/tests/test_aggregation.py b/scripts/forecast/tests/test_aggregation.py new file mode 100644 index 0000000..69a845c --- /dev/null +++ b/scripts/forecast/tests/test_aggregation.py @@ -0,0 +1,63 @@ +# scripts/forecast/tests/test_aggregation.py +import pandas as pd +from datetime import date +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly + +def test_bucket_to_weekly_iso_monday_start(): + # 2026-04-26 is a Sunday; 2026-04-27 is a Monday (start of new ISO week) + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-04-20', '2026-04-21', '2026-04-26', '2026-04-27']), + 'revenue_eur': [100, 150, 200, 175] + }) + out = bucket_to_weekly(df, value_col='revenue_eur') + assert len(out) == 2 + # Week starting 2026-04-20 (Mon): 100 + 150 + 200 = 450 + # Week starting 2026-04-27 (Mon): 175 + assert out.iloc[0]['week_start'] == pd.Timestamp('2026-04-20') + assert out.iloc[0]['revenue_eur'] == 450 + assert out.iloc[1]['revenue_eur'] == 175 + +def test_bucket_to_monthly_first_of_month_start(): + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-03-31', '2026-04-01', '2026-04-30', '2026-05-01']), + 'invoice_count': [10, 12, 15, 8] + }) + out = bucket_to_monthly(df, value_col='invoice_count') + assert len(out) == 3 + months = sorted(out['month_start'].dt.strftime('%Y-%m-%d').tolist()) + assert months == ['2026-03-01', '2026-04-01', '2026-05-01'] + +def test_bucket_to_weekly_excludes_partial_week(): + # When the input ends mid-week, the partial trailing week is dropped + # by the consumer (run_all.py uses TRAIN_END as the cutoff). + df = pd.DataFrame({ + 'business_date': pd.to_datetime(['2026-04-19']), # Sun (last day of prior week) + 'revenue_eur': [100] + }) + out = bucket_to_weekly(df, value_col='revenue_eur') + assert len(out) == 1 + assert out.iloc[0]['week_start'] == pd.Timestamp('2026-04-13') # Mon + + +def test_bucket_to_weekly_accepts_date_col_override(): + # 15-10: model fit scripts rename business_date -> date before calling + # the aggregation helpers, so the date_col override must work. + df = pd.DataFrame({ + 'date': pd.to_datetime(['2026-04-20', '2026-04-21']), + 'revenue_eur': [100, 50], + }) + out = bucket_to_weekly(df, value_col='revenue_eur', date_col='date') + assert len(out) == 1 + assert out.iloc[0]['week_start'] == pd.Timestamp('2026-04-20') + assert out.iloc[0]['revenue_eur'] == 150 + + +def test_bucket_to_monthly_accepts_date_col_override(): + df = pd.DataFrame({ + 'date': pd.to_datetime(['2026-04-15', '2026-04-30']), + 'invoice_count': [3, 4], + }) + out = bucket_to_monthly(df, value_col='invoice_count', date_col='date') + assert len(out) == 1 + assert out.iloc[0]['month_start'] == pd.Timestamp('2026-04-01') + assert out.iloc[0]['invoice_count'] == 7 diff --git a/scripts/forecast/tests/test_run_all_grain_loop.py b/scripts/forecast/tests/test_run_all_grain_loop.py new file mode 100644 index 0000000..4a1afea --- /dev/null +++ b/scripts/forecast/tests/test_run_all_grain_loop.py @@ -0,0 +1,157 @@ +"""15-10 Step 4: integration test for run_all triple-grain loop + freshness gate. + +Asserts that scripts.forecast.run_all.main: + - spawns model x KPI x grain subprocesses (3 grains tagged correctly) + - aborts cleanly (return 0, no spawns) when last_actual is stale > 8 days + +The subprocess + Supabase client are both mocked so the test runs offline +and in <1s. The supabase package isn't required at test time — we stub +the symbols (create_client, Client) into sys.modules before run_all +imports it transitively via scripts.forecast.db and pipeline_runs_writer. +""" +from __future__ import annotations +import sys +import types +from datetime import date, timedelta +from unittest.mock import MagicMock, patch + +import pytest + + +# ---- Stub the supabase package BEFORE run_all is imported. +# scripts.forecast.db does `from supabase import create_client, Client` at +# import time; pipeline_runs_writer does `from supabase import Client`. If +# the real supabase package isn't installed (true in some local envs and +# in CI where we don't pin it for unit tests), the import explodes. The +# stub satisfies the import; we never call into either symbol because +# make_client is patched out in each test. +if 'supabase' not in sys.modules: + _supabase_stub = types.ModuleType('supabase') + _supabase_stub.create_client = lambda *a, **kw: None # never called + _supabase_stub.Client = type('Client', (), {}) # type-hint only + sys.modules['supabase'] = _supabase_stub + + +def _make_table_response(*, count=None, data=None): + """Build a SimpleNamespace-style response object that mimics supabase-py's shape.""" + resp = types.SimpleNamespace() + resp.count = count + resp.data = data if data is not None else [] + return resp + + +def _build_mock_client(*, last_actual_iso: str): + """Mock supabase client supporting all queries run_all.main makes. + + last_actual_iso controls what max(business_date) the freshness gate sees. + """ + client = MagicMock(name='supabase_client') + + # ---- weather_daily: count=1 so the weather guard passes. + weather_chain = MagicMock() + weather_chain.select.return_value = weather_chain + weather_chain.limit.return_value = weather_chain + weather_chain.execute.return_value = _make_table_response(count=1) + + # ---- restaurants: returns one restaurant id. + restaurants_chain = MagicMock() + restaurants_chain.select.return_value = restaurants_chain + restaurants_chain.limit.return_value = restaurants_chain + restaurants_chain.execute.return_value = _make_table_response( + data=[{'id': 'rest-1'}] + ) + + # ---- kpi_daily_mv: returns the chosen last_actual. + kpi_chain = MagicMock() + kpi_chain.select.return_value = kpi_chain + kpi_chain.eq.return_value = kpi_chain + kpi_chain.order.return_value = kpi_chain + kpi_chain.limit.return_value = kpi_chain + kpi_chain.execute.return_value = _make_table_response( + data=[{'business_date': last_actual_iso}] + ) + + def table_router(name): + if name == 'weather_daily': + return weather_chain + if name == 'restaurants': + return restaurants_chain + if name == 'kpi_daily_mv': + return kpi_chain + # Anything else (e.g. pipeline_runs) — return a fresh mock that + # absorbs every method call and returns an empty response. + catchall = MagicMock() + catchall.select.return_value = catchall + catchall.eq.return_value = catchall + catchall.gte.return_value = catchall + catchall.lte.return_value = catchall + catchall.order.return_value = catchall + catchall.limit.return_value = catchall + catchall.insert.return_value = catchall + catchall.upsert.return_value = catchall + catchall.execute.return_value = _make_table_response(data=[]) + return catchall + + client.table.side_effect = table_router + + # rpc('refresh_forecast_mvs', {}).execute() — chained no-op + rpc_chain = MagicMock() + rpc_chain.execute.return_value = _make_table_response(data=[]) + client.rpc.return_value = rpc_chain + + return client + + +# Required env vars: _build_subprocess_env enforces these. Patch them in +# at session setup so the test never depends on the developer's shell. +@pytest.fixture(autouse=True) +def _supabase_env(monkeypatch): + monkeypatch.setenv('SUPABASE_URL', 'http://test.local') + monkeypatch.setenv('SUPABASE_SERVICE_ROLE_KEY', 'test-role-key') + + +def test_run_all_loops_over_three_granularities(): + """1 model x 2 KPIs x 3 grains = 6 spawns, each with a distinct GRANULARITY env.""" + last_actual = (date.today() - timedelta(days=1)).isoformat() + mock_client = _build_mock_client(last_actual_iso=last_actual) + + with patch('scripts.forecast.run_all.make_client', return_value=mock_client): + with patch('scripts.forecast.run_all.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout='', stderr='' + ) + # evaluate_last_7 reads from forecast_daily; stub it out. + with patch('scripts.forecast.run_all.evaluate_last_7'): + from scripts.forecast.run_all import main + rc = main(models=['sarimax']) + + assert rc == 0 + assert mock_run.call_count == 6 + spawned_grains = [ + call.kwargs['env']['GRANULARITY'] for call in mock_run.call_args_list + ] + assert sorted(spawned_grains) == ['day', 'day', 'month', 'month', 'week', 'week'] + + # Sanity: KPIs covered too. + spawned_kpis = sorted( + call.kwargs['env']['KPI_NAME'] for call in mock_run.call_args_list + ) + assert spawned_kpis == [ + 'invoice_count', 'invoice_count', 'invoice_count', + 'revenue_eur', 'revenue_eur', 'revenue_eur', + ] + + +def test_freshness_gate_aborts_on_stale_data(): + """If last_actual is more than FRESHNESS_GATE_DAYS old, abort cleanly: rc=0, no spawns.""" + stale = (date.today() - timedelta(days=10)).isoformat() + mock_client = _build_mock_client(last_actual_iso=stale) + + with patch('scripts.forecast.run_all.make_client', return_value=mock_client): + with patch('scripts.forecast.run_all.subprocess.run') as mock_run: + with patch('scripts.forecast.run_all.evaluate_last_7'): + from scripts.forecast.run_all import main + rc = main(models=['sarimax']) + + assert mock_run.call_count == 0, 'No subprocesses should spawn on stale data' + assert rc == 0, 'Stale data is a clean abort, not a workflow failure' diff --git a/scripts/forecast/theta_fit.py b/scripts/forecast/theta_fit.py index 82c13c2..e5d4ff0 100644 --- a/scripts/forecast/theta_fit.py +++ b/scripts/forecast/theta_fit.py @@ -1,22 +1,26 @@ -"""Phase 14: AutoTheta model fit and forecast writer. +"""Phase 14 / 15-10: AutoTheta model fit and forecast writer. Subprocess entry point — run as: python -m scripts.forecast.theta_fit -Reads RESTAURANT_ID, KPI_NAME, RUN_DATE from env vars. -Writes 365 rows to forecast_daily via chunked upsert (100 rows/chunk). +Reads RESTAURANT_ID, KPI_NAME, RUN_DATE, GRANULARITY from env vars. Design decisions: - D-03: Train on open-day-only series. - D-16: Bootstrap residuals for 200 sample paths (no native simulate in StatsForecast). + D-03: Daily grain trains on open-day-only series. Weekly/monthly grains + aggregate the full daily history (closed days roll into bucket sums). + D-16: Bootstrap residuals for 200 sample paths (no native simulate in + StatsForecast). No exog — Theta is purely univariate. + +15-10: GRANULARITY env (day|week|month) selects native bucket cadence, +TRAIN_END (D-14), horizon, and season_length. """ from __future__ import annotations import json import os import sys import traceback -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone import numpy as np import pandas as pd @@ -25,23 +29,27 @@ from scripts.forecast.db import make_client from scripts.forecast.closed_days import zero_closed_days, filter_open_days +from scripts.forecast.aggregation import bucket_to_weekly, bucket_to_monthly from scripts.forecast.sample_paths import bootstrap_from_residuals, paths_to_jsonb +from scripts.forecast.grain_helpers import ( + HORIZON_BY_GRAIN, + parse_granularity_env, + pred_dates_for_grain, + train_end_for_grain, +) from scripts.external.pipeline_runs_writer import write_success, write_failure # --- Constants --- N_PATHS = 200 -HORIZON = 365 STEP_NAME = 'forecast_theta' CHUNK_SIZE = 100 -SEASON_LENGTH = 7 # weekly seasonality +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. +SEASON_LENGTH_BY_GRAIN = {'day': 7, 'week': 52, 'month': 12} -def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: - """Fetch kpi_daily_mv history and shop_calendar is_open for open-day filtering. - kpi_daily_mv has columns: business_date, revenue_cents, tx_count. - is_open comes from shop_calendar (not kpi_daily_mv). - """ +def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: + """Fetch kpi_daily_mv history and shop_calendar is_open for open-day filtering.""" resp = ( client.table('kpi_daily_mv') .select('business_date,revenue_cents,tx_count') @@ -54,14 +62,12 @@ def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame if not rows: raise RuntimeError(f'No history found for restaurant_id={restaurant_id}') df = pd.DataFrame(rows) - # Map actual MV columns to canonical names df.rename(columns={'business_date': 'date'}, inplace=True) df['date'] = pd.to_datetime(df['date']).dt.date df['revenue_eur'] = df['revenue_cents'] / 100.0 df['invoice_count'] = df['tx_count'].astype(float) df = df.sort_values('date').reset_index(drop=True) - # Fetch is_open from shop_calendar (kpi_daily_mv does not have this column) cal_resp = ( client.table('shop_calendar') .select('date,is_open') @@ -78,7 +84,6 @@ def _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame cal_lookup = dict(zip(cal_df['date'], cal_df['is_open'])) df['is_open'] = [cal_lookup.get(d, True) for d in df['date']] else: - # Default: assume all days open if no shop_calendar data df['is_open'] = True if kpi_name not in df.columns: @@ -104,16 +109,17 @@ def _fetch_shop_calendar(client, *, restaurant_id: str, start_date: date, end_da return df -def _fit_theta(y: np.ndarray) -> tuple: - """Fit AutoTheta on open-day series. +def _fit_theta(y: np.ndarray, *, season_length: int) -> tuple: + """Fit AutoTheta on a 1-D series. StatsForecast expects a DataFrame with columns: unique_id, ds, y. - freq=1 means integer-indexed (step = one open day). + freq=1 means integer-indexed (step = one bucket; we don't expose calendar + dates to StatsForecast since open-day filtering / bucket cadence already + aligns rows). - Returns (fitted StatsForecast object, in-sample fitted values for residual computation). + Returns (fitted StatsForecast object, training DataFrame). """ n = len(y) - # Build integer time index (open-day index, not calendar dates) train_df = pd.DataFrame({ 'unique_id': ['ts'] * n, 'ds': np.arange(n), @@ -121,7 +127,7 @@ def _fit_theta(y: np.ndarray) -> tuple: }) sf = StatsForecast( - models=[AutoTheta(season_length=SEASON_LENGTH)], + models=[AutoTheta(season_length=season_length)], freq=1, ) sf.fit(train_df) @@ -135,7 +141,7 @@ def _open_future_dates(shop_cal: pd.DataFrame, pred_dates: list) -> list: return [d for d in pred_dates if d not in cal_dates or d in open_set] -def _build_forecast_rows( +def _build_forecast_rows_daily( *, samples: np.ndarray, open_dates: list, @@ -143,8 +149,10 @@ def _build_forecast_rows( restaurant_id: str, kpi_name: str, run_date: date, -) -> list[dict]: - """Map open-day samples to calendar forecast rows. Closed dates get yhat=0.""" + granularity: str, + season_length: int, +) -> list: + """Daily-grain row builder. Closed dates get yhat=0 (fixed up by zero_closed_days).""" open_date_idx = {d: i for i, d in enumerate(open_dates)} rows = [] @@ -169,17 +177,51 @@ def _build_forecast_rows( 'model_name': 'theta', 'run_date': str(run_date), 'forecast_track': 'bau', + 'granularity': granularity, 'yhat': round(yhat, 4), 'yhat_lower': round(yhat_lower, 4), 'yhat_upper': round(yhat_upper, 4), 'yhat_samples': yhat_samples_json, - 'exog_signature': json.dumps({'model': 'theta', 'season_length': SEASON_LENGTH}), + 'exog_signature': json.dumps({'model': 'theta', 'season_length': season_length}), + }) + return rows + + +def _build_forecast_rows_bucket( + *, + samples: np.ndarray, + pred_dates: list, + restaurant_id: str, + kpi_name: str, + run_date: date, + granularity: str, + season_length: int, +) -> list: + """Weekly/monthly row builder.""" + rows = [] + for i, target_date in enumerate(pred_dates): + path_values = samples[i] + yhat = float(np.mean(path_values)) + yhat_lower = float(np.percentile(path_values, 10)) + yhat_upper = float(np.percentile(path_values, 90)) + rows.append({ + 'restaurant_id': restaurant_id, + 'kpi_name': kpi_name, + 'target_date': str(target_date), + 'model_name': 'theta', + 'run_date': str(run_date), + 'forecast_track': 'bau', + 'granularity': granularity, + 'yhat': round(yhat, 4), + 'yhat_lower': round(yhat_lower, 4), + 'yhat_upper': round(yhat_upper, 4), + 'yhat_samples': paths_to_jsonb(samples, i), + 'exog_signature': json.dumps({'model': 'theta', 'season_length': season_length}), }) return rows -def _upsert_rows(client, rows: list[dict]) -> int: - """Upsert rows in chunks of CHUNK_SIZE. Returns total count inserted/updated.""" +def _upsert_rows(client, rows: list) -> int: total = 0 for start in range(0, len(rows), CHUNK_SIZE): chunk = rows[start:start + CHUNK_SIZE] @@ -194,88 +236,140 @@ def fit_and_write( restaurant_id: str, kpi_name: str, run_date: date, + granularity: str = 'day', ) -> int: - """Core logic: fit AutoTheta on open days, bootstrap 200 paths, write 365 rows. + """Core logic: fit AutoTheta at the chosen grain, bootstrap paths, write rows.""" + horizon = HORIZON_BY_GRAIN[granularity] + season_length = SEASON_LENGTH_BY_GRAIN[granularity] - Returns the number of rows written to forecast_daily. - """ - # 1. Fetch training history + # 1. Fetch training history. history = _fetch_history(client, restaurant_id=restaurant_id, kpi_name=kpi_name) + last_actual = history['date'].iloc[-1] + train_end = train_end_for_grain(last_actual, granularity) + print( + f'[theta_fit] grain={granularity} last_actual={last_actual} ' + f'train_end={train_end} horizon={horizon} season_length={season_length}' + ) - # 2. Filter to open days only (D-03) - open_history = filter_open_days(history) - if len(open_history) < SEASON_LENGTH * 2: - raise RuntimeError( - f'Insufficient open-day history: {len(open_history)} rows (need >= {SEASON_LENGTH * 2})' + # 2. Truncate to <= train_end. + history = history[history['date'] <= train_end].reset_index(drop=True) + if history.empty: + raise RuntimeError(f'Empty history after train_end cutoff {train_end}') + + if granularity == 'day': + # 3a. Daily path: open-day-only fit + closed-day post-hoc zeroing. + open_history = filter_open_days(history) + if len(open_history) < season_length * 2: + raise RuntimeError( + f'Insufficient open-day history: {len(open_history)} rows (need >= {season_length * 2})' + ) + y = open_history['y'].values + + sf, _ = _fit_theta(y, season_length=season_length) + print(f'[theta_fit] Fitted AutoTheta for {kpi_name}/day on {len(y)} open-day observations') + + all_pred_dates = pred_dates_for_grain( + run_date=run_date, granularity='day', horizon=horizon, ) - y = open_history['y'].values - - # 3. Fit AutoTheta model - sf, train_df = _fit_theta(y) - print(f'[theta_fit] Fitted AutoTheta for {kpi_name} on {len(y)} open-day observations') - - # 4. Define prediction window - pred_start = run_date + timedelta(days=1) - pred_end = run_date + timedelta(days=HORIZON) - all_pred_dates = [pred_start + timedelta(days=i) for i in range(HORIZON)] - - # 5. Fetch shop calendar and find open future dates - shop_cal = _fetch_shop_calendar( - client, - restaurant_id=restaurant_id, - start_date=pred_start, - end_date=pred_end, - ) - open_future = _open_future_dates(shop_cal, all_pred_dates) - n_open = len(open_future) - if n_open == 0: - raise RuntimeError('No open days in forecast window — check shop_calendar') - - # 6. Point forecast for n_open open days + fitted values for residuals - # forecast(fitted=True) enables forecast_fitted_values() afterwards - pred_df = sf.forecast(h=n_open, fitted=True) - point_forecast = pred_df['AutoTheta'].values # shape: (n_open,) - - # 7. Compute in-sample residuals for bootstrap (D-16) - fitted_df = sf.forecast_fitted_values() - fitted_vals = fitted_df['AutoTheta'].values - residuals = y[:len(fitted_vals)] - fitted_vals - residuals = residuals[~np.isnan(residuals)] # strip NaN warm-up period - - # 8. Bootstrap 200 sample paths from residuals (D-16) - samples = bootstrap_from_residuals( - point_forecast=point_forecast, - residuals=residuals, - n_paths=N_PATHS, - ) - assert samples.shape == (n_open, N_PATHS), f'Unexpected samples shape: {samples.shape}' - - # 9. Build forecast rows - rows = _build_forecast_rows( - samples=samples, - open_dates=open_future, - all_pred_dates=all_pred_dates, - restaurant_id=restaurant_id, - kpi_name=kpi_name, - run_date=run_date, - ) - preds_df = pd.DataFrame(rows) - preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + shop_cal = _fetch_shop_calendar( + client, + restaurant_id=restaurant_id, + start_date=all_pred_dates[0], + end_date=all_pred_dates[-1], + ) + open_future = _open_future_dates(shop_cal, all_pred_dates) + n_open = len(open_future) + if n_open == 0: + raise RuntimeError('No open days in forecast window — check shop_calendar') + + pred_df = sf.forecast(h=n_open, fitted=True) + point_forecast = pred_df['AutoTheta'].values + + fitted_df = sf.forecast_fitted_values() + fitted_vals = fitted_df['AutoTheta'].values + residuals = y[:len(fitted_vals)] - fitted_vals + residuals = residuals[~np.isnan(residuals)] + + samples = bootstrap_from_residuals( + point_forecast=point_forecast, + residuals=residuals, + n_paths=N_PATHS, + ) + assert samples.shape == (n_open, N_PATHS), f'Unexpected samples shape: {samples.shape}' + + rows = _build_forecast_rows_daily( + samples=samples, + open_dates=open_future, + all_pred_dates=all_pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity='day', + season_length=season_length, + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + preds_df = zero_closed_days(preds_df, shop_cal) + else: + # 3b. Weekly/monthly: aggregate full series, fit on bucket counts. + if granularity == 'week': + agg = bucket_to_weekly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'week_start': 'bucket_start'}) + else: # 'month' + agg = bucket_to_monthly(history, value_col='y', date_col='date') + agg = agg.rename(columns={'month_start': 'bucket_start'}) + if agg.empty: + raise RuntimeError(f'Empty aggregation for grain={granularity}') + + y = agg['y'].astype(float).values + if len(y) < season_length * 2: + raise RuntimeError( + f'Insufficient {granularity} history: {len(y)} buckets (need >= {season_length * 2})' + ) - # 10. Zero closed days post-hoc (belt-and-suspenders) - preds_df = zero_closed_days(preds_df, shop_cal) + sf, _ = _fit_theta(y, season_length=season_length) + print(f'[theta_fit] Fitted AutoTheta for {kpi_name}/{granularity} on {len(y)} buckets') - # 11. Restore target_date to str for upsert + pred_dates = pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + pred_df = sf.forecast(h=horizon, fitted=True) + point_forecast = pred_df['AutoTheta'].values + + fitted_df = sf.forecast_fitted_values() + fitted_vals = fitted_df['AutoTheta'].values + residuals = y[:len(fitted_vals)] - fitted_vals + residuals = residuals[~np.isnan(residuals)] + + samples = bootstrap_from_residuals( + point_forecast=point_forecast, + residuals=residuals, + n_paths=N_PATHS, + ) + assert samples.shape == (horizon, N_PATHS), f'Unexpected samples shape: {samples.shape}' + + rows = _build_forecast_rows_bucket( + samples=samples, + pred_dates=pred_dates, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + season_length=season_length, + ) + preds_df = pd.DataFrame(rows) + preds_df['target_date'] = pd.to_datetime(preds_df['target_date']).dt.date + + # 4. Restore target_date to str for upsert preds_df['target_date'] = preds_df['target_date'].astype(str) - # 12. Chunked upsert + # 5. Chunked upsert final_rows = preds_df.to_dict(orient='records') n = _upsert_rows(client, final_rows) return n if __name__ == '__main__': - # Read env vars restaurant_id = os.environ.get('RESTAURANT_ID', '').strip() kpi_name = os.environ.get('KPI_NAME', '').strip() run_date_str = os.environ.get('RUN_DATE', '').strip() @@ -283,13 +377,24 @@ def fit_and_write( if not restaurant_id or not kpi_name or not run_date_str: print('ERROR: RESTAURANT_ID, KPI_NAME, and RUN_DATE env vars are required', file=sys.stderr) sys.exit(1) + try: + granularity = parse_granularity_env(os.environ.get('GRANULARITY')) + except ValueError as e: + print(f'ERROR: {e}', file=sys.stderr) + sys.exit(1) run_date = date.fromisoformat(run_date_str) started_at = datetime.now(timezone.utc) client = make_client() try: - n = fit_and_write(client, restaurant_id=restaurant_id, kpi_name=kpi_name, run_date=run_date) + n = fit_and_write( + client, + restaurant_id=restaurant_id, + kpi_name=kpi_name, + run_date=run_date, + granularity=granularity, + ) write_success( client, step_name=STEP_NAME, @@ -297,7 +402,7 @@ def fit_and_write( row_count=n, restaurant_id=restaurant_id, ) - print(f'[theta_fit] Done: {n} rows written for {kpi_name}') + print(f'[theta_fit] Done: {n} rows written for {kpi_name}/{granularity}') sys.exit(0) except Exception: err_msg = traceback.format_exc() diff --git a/src/lib/chartPalettes.ts b/src/lib/chartPalettes.ts index e04a769..2ea7912 100644 --- a/src/lib/chartPalettes.ts +++ b/src/lib/chartPalettes.ts @@ -37,3 +37,32 @@ export const COHORT_LINE_PALETTE: readonly string[] = [ '#ea580c', '#ca8a04', '#16a34a', '#0d9488', '#7e22ce', '#be123c', '#4d7c0f', '#b45309' ]; + +/** + * Phase 15 D-10: per-model line color for RevenueForecastCard. + * + * Categorical palette (no ranking implied — Phase 17 backtest gate is what + * promotes models). Slice [0..3] of schemeTableau10 for the four BAU "smart" + * models. naive_dow is the de-emphasized baseline drawn dashed in gray (same + * value as CASH_COLOR keeps "neutral / not-the-headline-series" visually + * consistent across the dashboard). Chronos + NeuralProphet pick up [5..6] + * for when Phase 14 D-09 feature flags flip on. + * + * sarimax = [0] blue + * prophet = [1] orange + * ets = [2] red + * theta = [3] teal — overlaps school-holiday background; OK because + * user opts theta IN; default state never renders both at once + * naive_dow = CASH_COLOR (#a1a1aa) — dashed stroke applied at site + * chronos = [5] + * neuralprophet = [6] + */ +export const FORECAST_MODEL_COLORS: Readonly> = { + sarimax: schemeTableau10[0], + prophet: schemeTableau10[1], + ets: schemeTableau10[2], + theta: schemeTableau10[3], + naive_dow: CASH_COLOR, + chronos: schemeTableau10[5], + neuralprophet: schemeTableau10[6] +}; diff --git a/src/lib/components/CalendarCountsCard.svelte b/src/lib/components/CalendarCountsCard.svelte index 022cd2c..bacbea8 100644 --- a/src/lib/components/CalendarCountsCard.svelte +++ b/src/lib/components/CalendarCountsCard.svelte @@ -2,14 +2,29 @@ // VA-05: Calendar customer counts — same stacked-bar shape as revenue card, // tx_count metric instead of revenue_cents. Title + testid differ only. // D-06 gradient + D-07 cash segment + D-08 shared legend. - import { Chart, Svg, Axis, Bars, Spline, Text, Tooltip } from 'layerchart'; + // + // Phase 15-13: Forecast overlay — per-model lines (Spline) + low-opacity CI bands + // (Area) on top of visit_seq stacked bars. Mirrors CalendarRevenueCard's 15-12 + // overlay; the y-values from /api/forecast?kpi=invoice_count are integer counts + // (no /100 divisor — invoice_count is INTEGER COUNT, unlike revenue_cents). + // + // Scale strategy: bars use a TIME scale (scaleTime + xInterval=day|week|month) so + // bars and forecast lines share the same x-axis. bucket key (yyyy-MM-dd or + // yyyy-MM) is parsed to a Date and stored as `bucket_d`; the original bucket + // label is kept in `bucket` for tooltip display only. + import { Chart, Svg, Axis, Bars, Spline, Area, Text, Tooltip } from 'layerchart'; + import { scaleTime } from 'd3-scale'; + import { timeDay, timeMonday, timeMonth } from 'd3-time'; + import { addDays, parseISO, format, startOfMonth, startOfWeek } from 'date-fns'; import { page } from '$app/state'; import { t } from '$lib/i18n/messages'; import EmptyState from './EmptyState.svelte'; import VisitSeqLegend from './VisitSeqLegend.svelte'; - import { VISIT_SEQ_COLORS, CASH_COLOR } from '$lib/chartPalettes'; + import ForecastLegend from './ForecastLegend.svelte'; + import { VISIT_SEQ_COLORS, CASH_COLOR, FORECAST_MODEL_COLORS } from '$lib/chartPalettes'; import { formatIntShort } from '$lib/format'; - import { bandCenterX, bucketTotals, bucketTrend } from '$lib/trendline'; + import { bucketTotals, bucketTrend } from '$lib/trendline'; + import { clientFetch } from '$lib/clientFetch'; import { getFiltered, getFilters, @@ -26,15 +41,87 @@ const VISIT_KEYS = ['1st', '2nd', '3rd', '4x', '5x', '6x', '7x', '8x+'] as const; + // ----- Forecast overlay state (Phase 15-13) ----- + type ForecastRow = { + target_date: string; + model_name: string; + yhat_mean: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; + }; + type ForecastPayload = { + rows: ForecastRow[]; + actuals: { date: string; value: number }[]; + events: unknown[]; + last_run: string | null; + kpi: 'revenue_eur' | 'invoice_count'; + granularity: 'day' | 'week' | 'month'; + }; + + let forecastData = $state(null); + let visibleModels = $state(new Set(['sarimax', 'naive_dow'])); + let lastFetchedGrain = $state(null); + + function toggleModel(modelName: string) { + // Always create a NEW Set to trigger Svelte 5 reactivity + const next = new Set(visibleModels); + if (next.has(modelName)) next.delete(modelName); + else next.add(modelName); + visibleModels = next; + } + + // Re-fetch /api/forecast when grain changes. Guard with lastFetchedGrain + // to prevent reactive loops if the response itself touches reactive state. + $effect(() => { + const grain = getFilters().grain as 'day' | 'week' | 'month'; + if (lastFetchedGrain === grain) return; + lastFetchedGrain = grain; + const url = `/api/forecast?kpi=invoice_count&granularity=${grain}`; + clientFetch(url) + .then((data) => { forecastData = data; }) + .catch(() => { forecastData = null; }); + }); + + // Group forecast rows per model, filtered by visibleModels. + const seriesByModel = $derived.by(() => { + const map = new Map(); + const rows = forecastData?.rows ?? []; + for (const r of rows) { + if (!visibleModels.has(r.model_name)) continue; + if (!map.has(r.model_name)) map.set(r.model_name, []); + map.get(r.model_name)!.push(r); + } + for (const arr of map.values()) { + arr.sort((a, b) => a.target_date.localeCompare(b.target_date)); + } + return map; + }); + + const availableModels = $derived( + Array.from(new Set((forecastData?.rows ?? []).map((r) => r.model_name))) + ); + + // Convert raw bucket key (yyyy-MM-dd or yyyy-MM) to a Date anchor at the + // bucket's left edge. Required for scaleTime + xInterval bar dimensioning. + function bucketKeyToDate(bucket: string, grain: 'day' | 'week' | 'month'): Date { + if (grain === 'month') return parseISO(bucket + '-01'); + return parseISO(bucket); // day/week — week key is the Monday yyyy-MM-dd + } + const chartData = $derived.by(() => { const filtered = getFiltered(); const grain = getFilters().grain as 'day' | 'week' | 'month'; const w = getWindow(); const nested = aggregateByBucketAndVisitSeq(filtered, grain); - return shapeForChart(nested, 'tx_count', bucketRange(w.from, w.to, grain)).map((r) => ({ - ...r, - bucket: formatBucketLabel(r.bucket as string, grain) - })); + return shapeForChart(nested, 'tx_count', bucketRange(w.from, w.to, grain)).map((r) => { + const rawBucket = r.bucket as string; + return { + ...r, + bucket: formatBucketLabel(rawBucket, grain), + bucket_d: bucketKeyToDate(rawBucket, grain) + }; + }); }); const series = $derived.by(() => { @@ -55,8 +142,44 @@ const trendData = $derived(bucketTrend(chartData, 'bucket', visibleKeys)); const totals = $derived(bucketTotals(chartData, visibleKeys)); + // Pick d3-time interval matching the grain (Bar.svelte uses + // xInterval.floor/offset to compute bar width on time scales). + const xInterval = $derived.by(() => { + const grain = getFilters().grain as 'day' | 'week' | 'month'; + if (grain === 'week') return timeMonday; + if (grain === 'month') return timeMonth; + return timeDay; + }); + + // X-axis tick formatter — switches based on grain (drops year for mobile fit). + const formatXTick = $derived.by(() => { + const grain = getFilters().grain as 'day' | 'week' | 'month'; + return (d: Date) => (grain === 'month' ? format(d, 'MMM') : format(d, 'MMM d')); + }); + + // X-domain: bars span [from, to]; forecast lines render in the +365d gap. + // Aligned to the grain's bucket boundary so bars don't get clipped. + const chartXDomain = $derived.by((): [Date, Date] => { + const w = getWindow(); + const grain = getFilters().grain as 'day' | 'week' | 'month'; + const fromD = parseISO(w.from); + const startAligned = + grain === 'month' ? startOfMonth(fromD) + : grain === 'week' ? startOfWeek(fromD, { weekStartsOn: 1 }) + : fromD; + return [startAligned, addDays(new Date(), 365)]; + }); + + // Scroll overflow: when bars don't fit at mobile width, force a wider chart + // and let the wrapper scroll horizontally. Forecast horizon adds ~365 day-slots + // worth of x-axis distance — without scaling chartW up, bars would be crushed. let cardW = $state(0); - const chartW = $derived(computeChartWidth(chartData.length, cardW)); + const totalSlots = $derived.by(() => { + const fcRows = forecastData?.rows ?? []; + const fcDates = new Set(fcRows.map((r) => r.target_date)); + return chartData.length + fcDates.size; + }); + const chartW = $derived(computeChartWidth(totalSlots, cardW)); // eslint-disable-next-line @typescript-eslint/no-explicit-any let chartCtx = $state(); @@ -73,10 +196,12 @@ - + {#each series as s, i (s.key)} = 2} ({ + ...r, + bucket_d: chartData[i]?.bucket_d ?? new Date() + }))} + x={(r: { bucket_d: Date }) => r.bucket_d} y="trend" class="stroke-zinc-900 stroke-[1.5] opacity-70" stroke-dasharray="3 3" /> {/if} - {#each chartData as row, i (row.bucket)} - {#if totals[i] > 0 && chartCtx} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (`band-${modelName}`)} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower} + y1={(r: { yhat_upper: number }) => r.yhat_upper} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (`line-${modelName}`)} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + + {#each chartData as row, i (String(row.bucket_d))} + {#if totals[i] > 0 && chartCtx && row.bucket_d instanceof Date} + {@const x0 = chartCtx.xScale(row.bucket_d) ?? 0} + {@const x1 = chartCtx.xScale(xInterval.offset(row.bucket_d, 1)) ?? x0} + {#if forecastData && availableModels.length > 0} + + {/if} {/if} diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index 23cd108..c4c59dd 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -1,18 +1,34 @@
@@ -91,14 +271,16 @@ {#if getFiltered().length === 0} {:else} -
+
- + {#each series as s, i (s.key)} = 2} ({ + ...r, + bucket_d: chartData[i]?.bucket_d ?? new Date() + }))} + x={(r: { bucket_d: Date }) => r.bucket_d} y="trend" class="stroke-zinc-900 stroke-[1.5] opacity-70" stroke-dasharray="3 3" /> {/if} - {#each chartData as row, i (row.bucket)} - {#if totals[i] > 0 && chartCtx} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (`band-${modelName}`)} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower} + y1={(r: { yhat_upper: number }) => r.yhat_upper} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (`line-${modelName}`)} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + + {#each chartData as row, i (String(row.bucket_d))} + {#if totals[i] > 0 && chartCtx && row.bucket_d instanceof Date} + {@const x0 = chartCtx.xScale(row.bucket_d) ?? 0} + {@const x1 = chartCtx.xScale(xInterval.offset(row.bucket_d, 1)) ?? x0}
+ {#if forecastData && availableModels.length > 0} + + {/if} {/if}
diff --git a/src/lib/components/EventMarker.svelte b/src/lib/components/EventMarker.svelte new file mode 100644 index 0000000..e5506ed --- /dev/null +++ b/src/lib/components/EventMarker.svelte @@ -0,0 +1,114 @@ + + + +{#each events as e (e.type + '|' + e.date + '|' + e.label)} + {#if e.type === 'school_holiday' && e.end_date} + {@const x0 = x(e.date)} + {@const x1 = x(e.end_date)} + + {e.label} + + {/if} +{/each} + + +{#each events as e (e.type + '|' + e.date + '|' + e.label)} + {#if e.type === 'campaign_start'} + + {e.label} + + {:else if e.type === 'holiday'} + + {e.label} + + {:else if e.type === 'recurring_event'} + + {e.label} + + {/if} +{/each} + + +{#each events as e (e.type + '|' + e.date + '|' + e.label)} + {#if e.type === 'transit_strike'} + + {e.label} + + {/if} +{/each} diff --git a/src/lib/components/ForecastHoverPopup.svelte b/src/lib/components/ForecastHoverPopup.svelte new file mode 100644 index 0000000..6ce7fef --- /dev/null +++ b/src/lib/components/ForecastHoverPopup.svelte @@ -0,0 +1,149 @@ + + +
+ +
+ {hoveredRow.model_name} + {hoveredRow.target_date} +
+ + +
+
+ {t(loc, 'popup_forecast')} + + {formatEUR(hoveredRow.yhat_mean * 100)} + +
+
+ {t(loc, 'popup_ci_95')} + + {formatEUR(hoveredRow.yhat_lower * 100)} – {formatEUR(hoveredRow.yhat_upper * 100)} + +
+
+ + +

+ {horizonText} +

+ + + {#if quality} +
+ {t(loc, 'popup_rmse')} + {quality.rmse.toFixed(0)} + + {t(loc, 'popup_mape')} + {(quality.mape * 100).toFixed(1)}% + + {t(loc, 'popup_bias')} + {quality.mean_bias.toFixed(1)} + + {#if quality.direction_hit_rate !== null} + {t(loc, 'popup_direction_hit')} + {Math.round(quality.direction_hit_rate * 100)}% + {/if} +
+ {:else} +

+ {t(loc, 'empty_forecast_quality_empty_body')} +

+ {/if} + + + {#if cumulativeDeviationEur !== null} +
+ {t(loc, 'popup_uplift_since_campaign')} + + {cumulativeDeviationEur >= 0 ? '+' : ''}{formatEUR(cumulativeDeviationEur * 100)} + +
+ {/if} + + + {#if lastRefitAgo} +

+ {t(loc, 'popup_last_refit', { ago: lastRefitAgo })} +

+ {/if} +
diff --git a/src/lib/components/ForecastLegend.svelte b/src/lib/components/ForecastLegend.svelte new file mode 100644 index 0000000..48a2dbd --- /dev/null +++ b/src/lib/components/ForecastLegend.svelte @@ -0,0 +1,88 @@ + + +
+ {#each PALETTE_ORDER as { key, labelKey } (key)} + {@const available = isAvailable(key)} + {@const pressed = isPressed(key)} + + {/each} +
diff --git a/src/lib/components/InvoiceCountForecastCard.svelte b/src/lib/components/InvoiceCountForecastCard.svelte new file mode 100644 index 0000000..7e86f02 --- /dev/null +++ b/src/lib/components/InvoiceCountForecastCard.svelte @@ -0,0 +1,208 @@ + + +
+

{t(page.data.locale, 'invoice_forecast_card_title')}

+

{t(page.data.locale, 'invoice_forecast_card_description')}

+ + {#if rows.length === 0} + + {:else} +
+ ({ ...r, target_date_d: parseISO(r.target_date) }))} + x="target_date_d" + y="yhat_mean" + xScale={scaleTime()} + yScale={scaleLinear()} + xDomain={xDomain} + yDomain={yDomain} + padding={{ left: 40, bottom: 24, top: 12, right: 8 }} + tooltipContext={{ mode: 'bisect-x', touchEvents: 'auto' }} + > + + formatIntShort(n)} grid /> + format(d, 'MMM d')} /> + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-band')} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower} + y1={(r: { yhat_upper: number }) => r.yhat_upper} + curve={curveMonotoneX} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-line')} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean} + curve={curveMonotoneX} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + + + {#if actuals.length > 0} + ({ d: parseISO(a.date), v: a.value }))} + x={(p: { d: Date }) => p.d} + y={(p: { v: number }) => p.v} + stroke="#0f172a" + stroke-width={2} + /> + {/if} + + + {#if chartCtx} + chartCtx.xScale(typeof d === 'string' ? parseISO(d) : d)} + height={chartCtx.height} + /> + {/if} + + + + + + {#snippet children({ data })} + {#if data} + + {/if} + {/snippet} + + +
+ + + {/if} +
diff --git a/src/lib/components/RevenueForecastCard.svelte b/src/lib/components/RevenueForecastCard.svelte new file mode 100644 index 0000000..f6ce648 --- /dev/null +++ b/src/lib/components/RevenueForecastCard.svelte @@ -0,0 +1,208 @@ + + +
+

{t(page.data.locale, 'forecast_card_title')}

+

{t(page.data.locale, 'forecast_card_description')}

+ + {#if rows.length === 0} + + {:else} +
+ ({ ...r, target_date_d: parseISO(r.target_date) }))} + x="target_date_d" + y="yhat_mean" + xScale={scaleTime()} + yScale={scaleLinear()} + xDomain={xDomain} + yDomain={yDomain} + padding={{ left: 40, bottom: 24, top: 12, right: 8 }} + tooltipContext={{ mode: 'bisect-x', touchEvents: 'auto' }} + > + + + format(d, 'MMM d')} /> + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-band')} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y0={(r: { yhat_lower: number }) => r.yhat_lower} + y1={(r: { yhat_upper: number }) => r.yhat_upper} + curve={curveMonotoneX} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} + /> + {/each} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-line')} + {@const isNaive = modelName === 'naive_dow'} + ({ ...r, d: parseISO(r.target_date) }))} + x={(r: { d: Date }) => r.d} + y={(r: { yhat_mean: number }) => r.yhat_mean} + curve={curveMonotoneX} + stroke={FORECAST_MODEL_COLORS[modelName]} + stroke-width={isNaive ? 1 : 2} + stroke-dasharray={isNaive ? '4 4' : undefined} + /> + {/each} + + + {#if actuals.length > 0} + ({ d: parseISO(a.date), v: a.value }))} + x={(p: { d: Date }) => p.d} + y={(p: { v: number }) => p.v} + stroke="#0f172a" + stroke-width={2} + /> + {/if} + + + {#if chartCtx} + chartCtx.xScale(typeof d === 'string' ? parseISO(d) : d)} + height={chartCtx.height} + /> + {/if} + + + + + + {#snippet children({ data })} + {#if data} + + {/if} + {/snippet} + + +
+ + + {/if} +
diff --git a/src/lib/emptyStates.ts b/src/lib/emptyStates.ts index 2dffed8..a11a125 100644 --- a/src/lib/emptyStates.ts +++ b/src/lib/emptyStates.ts @@ -19,7 +19,14 @@ export const emptyStates = { // quick-260424-mdc: MDE curve needs ≥ 7 baseline days to draw. // Heading reuses the card title; body carries the why. - 'mde-curve': { headingKey: 'mde_title', bodyKey: 'mde_empty' } + 'mde-curve': { headingKey: 'mde_title', bodyKey: 'mde_empty' }, + + // Phase 15 FUI-08 + 'forecast-loading': { headingKey: 'empty_forecast_loading_heading', bodyKey: 'empty_forecast_loading_body' }, + 'forecast-quality-empty': { headingKey: 'empty_forecast_quality_empty_heading', bodyKey: 'empty_forecast_quality_empty_body' }, + 'forecast-stale': { headingKey: 'empty_forecast_stale_heading', bodyKey: 'empty_forecast_stale_body' }, + 'forecast-uncalibrated-ci': { headingKey: 'empty_forecast_uncalibrated_ci_heading', bodyKey: 'empty_forecast_uncalibrated_ci_body' }, + 'forecast-grain-pending': { headingKey: 'empty_forecast_grain_pending_heading', bodyKey: 'empty_forecast_grain_pending_body' } } as const satisfies Record; export type EmptyCard = keyof typeof emptyStates; diff --git a/src/lib/forecastConfig.ts b/src/lib/forecastConfig.ts new file mode 100644 index 0000000..996cc1a --- /dev/null +++ b/src/lib/forecastConfig.ts @@ -0,0 +1,11 @@ +// src/lib/forecastConfig.ts +// Phase 15 D-08: hard-coded campaign-start for the cumulative-deviation calc +// in /api/campaign-uplift and ForecastHoverPopup. +// +// Phase 16 replaces this constant with a campaign_calendar table lookup; +// the endpoint URL contract /api/campaign-uplift remains stable, only the +// backing data source changes. Keep this file as the SINGLE source of the +// 2026-04-14 literal — a Phase 16 CI grep guard will forbid the literal +// reappearing anywhere else in src/. + +export const CAMPAIGN_START: Date = new Date('2026-04-14T00:00:00Z'); diff --git a/src/lib/forecastEventClamp.ts b/src/lib/forecastEventClamp.ts new file mode 100644 index 0000000..8505607 --- /dev/null +++ b/src/lib/forecastEventClamp.ts @@ -0,0 +1,63 @@ +// src/lib/forecastEventClamp.ts +// Phase 15 D-09 / FUI-05: progressive disclosure for event markers. +// Default cap = 50 markers per chart. When the horizon (1yr at month grain +// has ~12 holiday + ~5 school_holiday + ~10 recurring + 1 campaign + N strikes) +// stays under 50, no clamp. When over, drop lowest-priority type first. +// +// Tie-break within a kept type: earliest date wins (visually nearer the +// "today" reference point, which is what the owner is reading). + +export type EventType = + | 'campaign_start' + | 'transit_strike' + | 'school_holiday' + | 'holiday' + | 'recurring_event'; + +export type ForecastEvent = { + type: EventType; + date: string; // YYYY-MM-DD (school_holiday block uses start_date) + label: string; + end_date?: string; // school_holiday only — block end +}; + +export const EVENT_PRIORITY: Record = { + campaign_start: 5, + transit_strike: 4, + school_holiday: 3, + holiday: 2, + recurring_event: 1 +}; + +// Dedupe events by (type, date, label). The /api/forecast handler queries +// `holidays` with `subdiv_code.is.null OR subdiv_code.eq.BE`, which can return +// two rows for the same calendar date when a federal holiday overlaps a Berlin +// state observance (both labeled "Tag der Arbeit", "Karfreitag", etc). +// Without dedupe, EventMarker's keyed-each `(e.type + '|' + e.date)` would +// duplicate-key-crash Svelte 5 at runtime. Beyond the crash, the chart cannot +// legibly render two markers stacked at one x — so dedupe is also a UX fix. +function dedupe(events: readonly ForecastEvent[]): ForecastEvent[] { + const seen = new Set(); + const out: ForecastEvent[] = []; + for (const e of events) { + const key = `${e.type}|${e.date}|${e.label}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(e); + } + return out; +} + +export function clampEvents(events: readonly ForecastEvent[], max = 50): ForecastEvent[] { + const deduped = dedupe(events); + if (deduped.length <= max) return deduped; + + // Sort: priority DESC, then date ASC (ties broken by earlier date kept). + const sorted = deduped.sort((a, b) => { + const dp = EVENT_PRIORITY[b.type] - EVENT_PRIORITY[a.type]; + if (dp !== 0) return dp; + return a.date.localeCompare(b.date); + }); + + return sorted.slice(0, max); +} diff --git a/src/lib/forecastValidation.ts b/src/lib/forecastValidation.ts new file mode 100644 index 0000000..59cf43d --- /dev/null +++ b/src/lib/forecastValidation.ts @@ -0,0 +1,29 @@ +// src/lib/forecastValidation.ts +// Phase 15 v2 D-14 — slimmed for native-grain endpoint. +// +// Phase 15 v1 carried a horizon × granularity clamp matrix because the +// endpoint resampled a single daily forecast into 7d/35d/120d/365d windows +// at run time. Plan 15-10 rewrote the model layer to fit one model per +// granularity, and plan 15-11 dropped resampling from /api/forecast — so +// the clamp matrix, DEFAULT_GRANULARITY map, and Horizon type all became +// dead code. They are removed here. +// +// What remains: parseHorizon + HORIZON_DAYS are still imported by +// HorizonToggle.svelte (rewritten in 15-14); parseGranularity + +// GRANULARITIES drive the new ?granularity= param. + +export const HORIZON_DAYS = [7, 35, 120, 365] as const; + +export const GRANULARITIES = ['day', 'week', 'month'] as const; +export type Granularity = typeof GRANULARITIES[number]; + +export function parseHorizon(raw: string | null): number | null { + if (raw === null) return null; + const n = Number(raw); + return (HORIZON_DAYS as readonly number[]).includes(n) ? n : null; +} + +export function parseGranularity(raw: string | null): Granularity | null { + if (raw === null) return null; + return (GRANULARITIES as readonly string[]).includes(raw) ? (raw as Granularity) : null; +} diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index a8b8464..e714431 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -22,6 +22,38 @@ const en = { grain_month: 'Month', grain_selector_aria: 'Grain selector', + // --- Forecast legend (Phase 15 D-04 / FUI-02) -------------------------- + legend_aria: 'Forecast model legend', + legend_model_sarimax: 'SARIMAX', + legend_model_prophet: 'Prophet', + legend_model_ets: 'ETS', + legend_model_theta: 'Theta', + legend_model_naive_dow: 'Naive (DoW)', + legend_model_chronos: 'Chronos', + legend_model_neuralprophet: 'NeuralProphet', + + // --- Forecast hover popup (Phase 15 FUI-04) ---------------------------- + popup_forecast: 'Forecast', + popup_ci_95: '95% CI', + popup_horizon_days_one: '{n} day from today', + popup_horizon_days_many: '{n} days from today', + popup_rmse: 'RMSE (last 7d)', + popup_mape: 'MAPE (last 7d)', + popup_bias: 'Bias (last 7d)', + popup_direction_hit: 'Direction hit rate', + popup_uplift_since_campaign: 'Δ since campaign', + popup_last_refit: 'Last refit {ago} ago', + + // --- Forecast card title + badges (Phase 15 D-01 / FUI-08) ------------- + forecast_card_title: 'Revenue forecast', + forecast_card_description: 'Tomorrow through next year — actuals vs. SARIMAX BAU.', + forecast_uncalibrated_badge: 'Uncalibrated CI', + forecast_today_label: 'Today', + + // --- Invoice count forecast card (Phase 15-15 / D-18) ------------------- + invoice_forecast_card_title: 'Invoice count forecast', + invoice_forecast_card_description: 'Tomorrow through next year — actual transactions vs. forecast.', + // --- KPI tiles (+page.svelte builds "Revenue · {range}") --------------- kpi_revenue: 'Revenue', kpi_transactions: 'Transactions', @@ -121,6 +153,18 @@ const en = { empty_cohort_avg_ltv_heading: 'Not enough history', empty_cohort_avg_ltv_body: 'Grouping charts need at least 5 customers per group.', + // --- Forecast empty states (Phase 15 FUI-08) ---------------------------- + empty_forecast_loading_heading: 'Forecast generating', + empty_forecast_loading_body: 'Check back tomorrow — the first nightly run is still pending.', + empty_forecast_quality_empty_heading: 'Accuracy data builds after first nightly run', + empty_forecast_quality_empty_body: 'Forecast accuracy metrics need at least one completed nightly evaluation cycle.', + empty_forecast_stale_heading: 'Data ≥24h stale', + empty_forecast_stale_body: 'Last refresh: {ago}. The nightly cascade may have skipped a run.', + empty_forecast_uncalibrated_ci_heading:'Uncalibrated for 1yr horizon', + empty_forecast_uncalibrated_ci_body: 'Need ≥2 years of history before the 1yr confidence band is reliable.', + empty_forecast_grain_pending_heading: 'Forecast not ready at this grain', + empty_forecast_grain_pending_body: 'Switch to 日 (day) — daily forecasts are live. Week and month forecasts populate on the next refresh.', + // --- InsightCard footer + edit form ------------------------------------ insight_week_ending: 'Week ending {date}', insight_refreshed_weekly: 'Refreshed weekly', @@ -167,6 +211,38 @@ const de: Record = { grain_month: 'Monat', grain_selector_aria: 'Zeitraster-Auswahl', + // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN + legend_aria: 'Forecast model legend', + legend_model_sarimax: 'SARIMAX', + legend_model_prophet: 'Prophet', + legend_model_ets: 'ETS', + legend_model_theta: 'Theta', + legend_model_naive_dow: 'Naive (DoW)', + legend_model_chronos: 'Chronos', + legend_model_neuralprophet: 'NeuralProphet', + + // Forecast hover popup (Phase 15 FUI-04) — placeholder copy mirrors EN + popup_forecast: 'Forecast', + popup_ci_95: '95% CI', + popup_horizon_days_one: '{n} day from today', + popup_horizon_days_many: '{n} days from today', + popup_rmse: 'RMSE (last 7d)', + popup_mape: 'MAPE (last 7d)', + popup_bias: 'Bias (last 7d)', + popup_direction_hit: 'Direction hit rate', + popup_uplift_since_campaign: 'Δ since campaign', + popup_last_refit: 'Last refit {ago} ago', + + // --- Forecast card title + badges (Phase 15 D-01 / FUI-08) ------------- + forecast_card_title: 'Revenue forecast', + forecast_card_description: 'Tomorrow through next year — actuals vs. SARIMAX BAU.', + forecast_uncalibrated_badge: 'Uncalibrated CI', + forecast_today_label: 'Today', + + // --- Invoice count forecast card (Phase 15-15 / D-18) ------------------- + invoice_forecast_card_title: 'Transaktionsanzahl-Prognose', + invoice_forecast_card_description: 'Morgen bis nächstes Jahr — tatsächliche Transaktionen vs. Prognose.', + kpi_revenue: 'Umsatz', kpi_transactions: 'Transaktionen', range_today: 'Heute', @@ -260,6 +336,18 @@ const de: Record = { empty_cohort_avg_ltv_heading: 'Zu wenig Verlauf', empty_cohort_avg_ltv_body: 'Gruppierungen benötigen mindestens 5 Kunden pro Gruppe.', + // Forecast empty states (Phase 15 FUI-08) — placeholder copy mirrors EN + empty_forecast_loading_heading: 'Forecast generating', + empty_forecast_loading_body: 'Check back tomorrow — the first nightly run is still pending.', + empty_forecast_quality_empty_heading: 'Accuracy data builds after first nightly run', + empty_forecast_quality_empty_body: 'Forecast accuracy metrics need at least one completed nightly evaluation cycle.', + empty_forecast_stale_heading: 'Data ≥24h stale', + empty_forecast_stale_body: 'Last refresh: {ago}. The nightly cascade may have skipped a run.', + empty_forecast_uncalibrated_ci_heading:'Uncalibrated for 1yr horizon', + empty_forecast_uncalibrated_ci_body: 'Need ≥2 years of history before the 1yr confidence band is reliable.', + empty_forecast_grain_pending_heading: 'Forecast not ready at this grain', + empty_forecast_grain_pending_body: 'Switch to 日 (day) — daily forecasts are live. Week and month forecasts populate on the next refresh.', + insight_week_ending: 'Woche endend am {date}', insight_refreshed_weekly: 'Wöchentlich aktualisiert', insight_refreshed_with_last_run: 'Wöchentlich aktualisiert · zuletzt {date}', @@ -303,6 +391,38 @@ const ja: Record = { grain_month: '月', grain_selector_aria: '期間粒度の選択', + // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN + legend_aria: 'Forecast model legend', + legend_model_sarimax: 'SARIMAX', + legend_model_prophet: 'Prophet', + legend_model_ets: 'ETS', + legend_model_theta: 'Theta', + legend_model_naive_dow: 'Naive (DoW)', + legend_model_chronos: 'Chronos', + legend_model_neuralprophet: 'NeuralProphet', + + // Forecast hover popup (Phase 15 FUI-04) — placeholder copy mirrors EN + popup_forecast: 'Forecast', + popup_ci_95: '95% CI', + popup_horizon_days_one: '{n} day from today', + popup_horizon_days_many: '{n} days from today', + popup_rmse: 'RMSE (last 7d)', + popup_mape: 'MAPE (last 7d)', + popup_bias: 'Bias (last 7d)', + popup_direction_hit: 'Direction hit rate', + popup_uplift_since_campaign: 'Δ since campaign', + popup_last_refit: 'Last refit {ago} ago', + + // --- Forecast card title + badges (Phase 15 D-01 / FUI-08) ------------- + forecast_card_title: 'Revenue forecast', + forecast_card_description: 'Tomorrow through next year — actuals vs. SARIMAX BAU.', + forecast_uncalibrated_badge: 'Uncalibrated CI', + forecast_today_label: 'Today', + + // --- Invoice count forecast card (Phase 15-15 / D-18) ------------------- + invoice_forecast_card_title: '取引件数の予測', + invoice_forecast_card_description: '明日から1年後まで — 実際の取引件数と予測の比較。', + kpi_revenue: '売上', kpi_transactions: '取引件数', range_today: '本日', @@ -395,6 +515,18 @@ const ja: Record = { empty_cohort_avg_ltv_heading: '履歴が不足', empty_cohort_avg_ltv_body: 'グループ表示には1グループあたり5名以上必要です。', + // Forecast empty states (Phase 15 FUI-08) — placeholder copy mirrors EN + empty_forecast_loading_heading: 'Forecast generating', + empty_forecast_loading_body: 'Check back tomorrow — the first nightly run is still pending.', + empty_forecast_quality_empty_heading: 'Accuracy data builds after first nightly run', + empty_forecast_quality_empty_body: 'Forecast accuracy metrics need at least one completed nightly evaluation cycle.', + empty_forecast_stale_heading: 'Data ≥24h stale', + empty_forecast_stale_body: 'Last refresh: {ago}. The nightly cascade may have skipped a run.', + empty_forecast_uncalibrated_ci_heading:'Uncalibrated for 1yr horizon', + empty_forecast_uncalibrated_ci_body: 'Need ≥2 years of history before the 1yr confidence band is reliable.', + empty_forecast_grain_pending_heading: 'この粒度の予測は準備中です', + empty_forecast_grain_pending_body: '「日」に切り替えると日次の予測を確認できます。週次・月次は次回更新で生成されます。', + insight_week_ending: '{date}終了週', insight_refreshed_weekly: '週次更新', insight_refreshed_with_last_run: '週次更新 · 最終実行 {date}', @@ -438,6 +570,38 @@ const es: Record = { grain_month: 'Mes', grain_selector_aria: 'Selector de granularidad', + // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN + legend_aria: 'Forecast model legend', + legend_model_sarimax: 'SARIMAX', + legend_model_prophet: 'Prophet', + legend_model_ets: 'ETS', + legend_model_theta: 'Theta', + legend_model_naive_dow: 'Naive (DoW)', + legend_model_chronos: 'Chronos', + legend_model_neuralprophet: 'NeuralProphet', + + // Forecast hover popup (Phase 15 FUI-04) — placeholder copy mirrors EN + popup_forecast: 'Forecast', + popup_ci_95: '95% CI', + popup_horizon_days_one: '{n} day from today', + popup_horizon_days_many: '{n} days from today', + popup_rmse: 'RMSE (last 7d)', + popup_mape: 'MAPE (last 7d)', + popup_bias: 'Bias (last 7d)', + popup_direction_hit: 'Direction hit rate', + popup_uplift_since_campaign: 'Δ since campaign', + popup_last_refit: 'Last refit {ago} ago', + + // --- Forecast card title + badges (Phase 15 D-01 / FUI-08) ------------- + forecast_card_title: 'Revenue forecast', + forecast_card_description: 'Tomorrow through next year — actuals vs. SARIMAX BAU.', + forecast_uncalibrated_badge: 'Uncalibrated CI', + forecast_today_label: 'Today', + + // --- Invoice count forecast card (Phase 15-15 / D-18) ------------------- + invoice_forecast_card_title: 'Pronóstico de transacciones', + invoice_forecast_card_description: 'De mañana hasta el próximo año — transacciones reales vs. pronóstico.', + kpi_revenue: 'Ingresos', kpi_transactions: 'Transacciones', range_today: 'Hoy', @@ -531,6 +695,18 @@ const es: Record = { empty_cohort_avg_ltv_heading: 'Historial insuficiente', empty_cohort_avg_ltv_body: 'Los gráficos de cohorte necesitan al menos 5 clientes por grupo.', + // Forecast empty states (Phase 15 FUI-08) — placeholder copy mirrors EN + empty_forecast_loading_heading: 'Forecast generating', + empty_forecast_loading_body: 'Check back tomorrow — the first nightly run is still pending.', + empty_forecast_quality_empty_heading: 'Accuracy data builds after first nightly run', + empty_forecast_quality_empty_body: 'Forecast accuracy metrics need at least one completed nightly evaluation cycle.', + empty_forecast_stale_heading: 'Data ≥24h stale', + empty_forecast_stale_body: 'Last refresh: {ago}. The nightly cascade may have skipped a run.', + empty_forecast_uncalibrated_ci_heading:'Uncalibrated for 1yr horizon', + empty_forecast_uncalibrated_ci_body: 'Need ≥2 years of history before the 1yr confidence band is reliable.', + empty_forecast_grain_pending_heading: 'Forecast not ready at this grain', + empty_forecast_grain_pending_body: 'Switch to 日 (day) — daily forecasts are live. Week and month forecasts populate on the next refresh.', + insight_week_ending: 'Semana que termina el {date}', insight_refreshed_weekly: 'Actualizado semanalmente', insight_refreshed_with_last_run: 'Actualizado semanalmente · última ejecución {date}', @@ -574,6 +750,38 @@ const fr: Record = { grain_month: 'Mois', grain_selector_aria: 'Sélecteur de granularité', + // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN + legend_aria: 'Forecast model legend', + legend_model_sarimax: 'SARIMAX', + legend_model_prophet: 'Prophet', + legend_model_ets: 'ETS', + legend_model_theta: 'Theta', + legend_model_naive_dow: 'Naive (DoW)', + legend_model_chronos: 'Chronos', + legend_model_neuralprophet: 'NeuralProphet', + + // Forecast hover popup (Phase 15 FUI-04) — placeholder copy mirrors EN + popup_forecast: 'Forecast', + popup_ci_95: '95% CI', + popup_horizon_days_one: '{n} day from today', + popup_horizon_days_many: '{n} days from today', + popup_rmse: 'RMSE (last 7d)', + popup_mape: 'MAPE (last 7d)', + popup_bias: 'Bias (last 7d)', + popup_direction_hit: 'Direction hit rate', + popup_uplift_since_campaign: 'Δ since campaign', + popup_last_refit: 'Last refit {ago} ago', + + // --- Forecast card title + badges (Phase 15 D-01 / FUI-08) ------------- + forecast_card_title: 'Revenue forecast', + forecast_card_description: 'Tomorrow through next year — actuals vs. SARIMAX BAU.', + forecast_uncalibrated_badge: 'Uncalibrated CI', + forecast_today_label: 'Today', + + // --- Invoice count forecast card (Phase 15-15 / D-18) ------------------- + invoice_forecast_card_title: 'Prévision du nombre de transactions', + invoice_forecast_card_description: "De demain à l'année prochaine — transactions réelles vs. prévision.", + kpi_revenue: "Chiffre d'affaires", kpi_transactions: 'Transactions', range_today: "Aujourd'hui", @@ -667,6 +875,18 @@ const fr: Record = { empty_cohort_avg_ltv_heading: 'Historique insuffisant', empty_cohort_avg_ltv_body: 'Les graphiques de cohorte nécessitent au moins 5 clients par groupe.', + // Forecast empty states (Phase 15 FUI-08) — placeholder copy mirrors EN + empty_forecast_loading_heading: 'Forecast generating', + empty_forecast_loading_body: 'Check back tomorrow — the first nightly run is still pending.', + empty_forecast_quality_empty_heading: 'Accuracy data builds after first nightly run', + empty_forecast_quality_empty_body: 'Forecast accuracy metrics need at least one completed nightly evaluation cycle.', + empty_forecast_stale_heading: 'Data ≥24h stale', + empty_forecast_stale_body: 'Last refresh: {ago}. The nightly cascade may have skipped a run.', + empty_forecast_uncalibrated_ci_heading:'Uncalibrated for 1yr horizon', + empty_forecast_uncalibrated_ci_body: 'Need ≥2 years of history before the 1yr confidence band is reliable.', + empty_forecast_grain_pending_heading: 'Forecast not ready at this grain', + empty_forecast_grain_pending_body: 'Switch to 日 (day) — daily forecasts are live. Week and month forecasts populate on the next refresh.', + insight_week_ending: 'Semaine se terminant le {date}', insight_refreshed_weekly: 'Actualisé chaque semaine', insight_refreshed_with_last_run: 'Actualisé chaque semaine · dernière exécution {date}', diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0d4abe5..6d2b574 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,6 +18,8 @@ import CalendarItemRevenueCard from '$lib/components/CalendarItemRevenueCard.svelte'; import MdeCurveCard from '$lib/components/MdeCurveCard.svelte'; import RepeaterCohortCountCard from '$lib/components/RepeaterCohortCountCard.svelte'; + import RevenueForecastCard from '$lib/components/RevenueForecastCard.svelte'; + import InvoiceCountForecastCard from '$lib/components/InvoiceCountForecastCard.svelte'; import LazyMount from '$lib/components/LazyMount.svelte'; import { clientFetch } from '$lib/clientFetch'; import { @@ -92,6 +94,14 @@ } catch (e) { console.error('[LazyMount /api/retention]', e); } } + // Phase 15-14: RevenueForecastCard self-fetches /api/forecast on grain + // change via getFilters().grain. The page no longer holds horizon / + // granularity state, no longer bundles forecast / quality / uplift + // payloads, and no longer needs the LazyMount-onvisible loader for them. + // The card's internal $effect runs on first render once the fragment + // mounts under LazyMount. The stale-data badge that consumed + // staleHours(data.freshness) was also dropped along with the prop. + // Initialize store from SSR data on mount and when SSR data changes. $effect(() => { initStore({ @@ -253,6 +263,28 @@ {/if} + + + {#snippet children()} + + {/snippet} + + + + + {#snippet children()} + + {/snippet} + +
= { 'Cache-Control': 'private, no-store' }; + +export const GET: RequestHandler = async ({ locals }) => { + const { claims } = await locals.safeGetSession(); + if (!claims) return json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE }); + + const campaignStartDate = format(CAMPAIGN_START, 'yyyy-MM-dd'); + + try { + const rows = await fetchAll(() => + locals.supabase + .from('forecast_with_actual_v') + .select('target_date,yhat,actual_value') + .eq('kpi_name', 'revenue_eur') + .eq('forecast_track', 'bau') + .eq('model_name', 'sarimax') + .gte('target_date', campaignStartDate) + ); + + let cumulative = 0; + for (const r of rows) { + if (r.actual_value !== null) cumulative += r.actual_value - r.yhat; + } + + return json( + { + campaign_start: campaignStartDate, + cumulative_deviation_eur: cumulative, + as_of: format(new Date(), 'yyyy-MM-dd') + }, + { headers: NO_STORE } + ); + } catch (err) { + console.error('[/api/campaign-uplift]', err); + return json({ error: 'query failed' }, { status: 500, headers: NO_STORE }); + } +}; diff --git a/src/routes/api/forecast-quality/+server.ts b/src/routes/api/forecast-quality/+server.ts new file mode 100644 index 0000000..9c3064d --- /dev/null +++ b/src/routes/api/forecast-quality/+server.ts @@ -0,0 +1,50 @@ +// src/routes/api/forecast-quality/+server.ts +// Phase 15 D-07 / FUI-04 / FUI-07. +// Deferred endpoint for ForecastHoverPopup. Long-format accuracy metrics +// per (model_name, kpi_name, horizon_days). Filtered to evaluation_window= +// 'last_7_days' so Phase 17 rolling-origin CV rows (evaluation_window= +// 'rolling_origin_cv') don't leak into the popup. +// +// Empty array on first 24h after Phase 14 ships (no rows yet) — the +// hover popup renders the 'forecast-quality-empty' empty-state copy +// "Accuracy data builds after first nightly run" in that case. +// +// Auth: locals.safeGetSession(). RLS: forecast_quality has its own +// per-tenant policy (migration 0051); no wrapper view needed because +// the table itself is tenant-scoped at the row level. +// Cache-Control: private, no-store. +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { fetchAll } from '$lib/supabasePagination'; + +type ForecastQualityRow = { + model_name: string; + kpi_name: string; + horizon_days: number; + rmse: number; + mape: number; + mean_bias: number; + direction_hit_rate: number | null; + evaluated_at: string; +}; + +const NO_STORE: Record = { 'Cache-Control': 'private, no-store' }; + +export const GET: RequestHandler = async ({ locals }) => { + const { claims } = await locals.safeGetSession(); + if (!claims) return json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE }); + + try { + const rows = await fetchAll(() => + locals.supabase + .from('forecast_quality') + .select('model_name,kpi_name,horizon_days,rmse,mape,mean_bias,direction_hit_rate,evaluated_at') + .eq('evaluation_window', 'last_7_days') + .order('evaluated_at', { ascending: false }) + ); + return json(rows, { headers: NO_STORE }); + } catch (err) { + console.error('[/api/forecast-quality]', err); + return json({ error: 'query failed' }, { status: 500, headers: NO_STORE }); + } +}; diff --git a/src/routes/api/forecast/+server.ts b/src/routes/api/forecast/+server.ts new file mode 100644 index 0000000..ebba9d3 --- /dev/null +++ b/src/routes/api/forecast/+server.ts @@ -0,0 +1,203 @@ +// src/routes/api/forecast/+server.ts +// Phase 15 v2 D-14 / D-15 / D-18. +// Returns native-grain forecasts joined with back-test-window actuals from +// kpi_daily_v. Drops resampling — Phase 14 v2 (15-10) writes rows at the +// native grain (day/week/month), one model run per grain per refresh, so +// the endpoint just filters forecast_with_actual_v on (kpi, granularity). +// +// Inputs: ?kpi=revenue_eur|invoice_count (default revenue_eur) +// ?granularity=day|week|month (required — no default) +// +// Auth: locals.safeGetSession() (canonical helper). RLS is enforced by +// forecast_with_actual_v's WHERE clause (auth.jwt()->>'restaurant_id') +// and kpi_daily_v's wrapper. Holidays / school_holidays / recurring_events / +// transit_alerts are global (public knowledge — no tenant scoping). +// pipeline_runs_status_v applies its own caller-JWT row filter (Phase 13 +// migration 0049). +// Cache-Control: private, no-store — prevents CDN cross-tenant leakage. +// CF Pages 50-subrequest budget: 7 parallel Supabase queries — well under cap. +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { fetchAll } from '$lib/supabasePagination'; +import { parseGranularity, type Granularity } from '$lib/forecastValidation'; +import { clampEvents, type ForecastEvent } from '$lib/forecastEventClamp'; +import { format, subDays, subMonths, startOfWeek, startOfMonth } from 'date-fns'; + +const KPIS = ['revenue_eur', 'invoice_count'] as const; +type Kpi = typeof KPIS[number]; + +type ForecastViewRow = { + target_date: string; + model_name: string; + granularity: Granularity; + yhat: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; + actual_value: number | null; + forecast_track: string; + kpi_name: string; +}; + +type DailyKpiRow = { business_date: string; revenue_cents: number; tx_count: number }; +type HolidayRow = { date: string; name: string; country_code: string; subdiv_code: string | null }; +type SchoolHolidayRow = { state_code: string; block_name: string; start_date: string; end_date: string }; +type RecurringEventRow = { event_id: string; name: string; start_date: string; end_date: string; impact_estimate: string }; +type TransitAlertRow = { alert_id: string; title: string; pub_date: string; matched_keyword: string }; +type PipelineRunRow = { step_name: string; status: string; finished_at: string | null }; + +const NO_STORE: Record = { 'Cache-Control': 'private, no-store' }; + +// Backtest window start: how far back of actuals to ship next to forecasts. +// day: last 7 days of actuals (small, dense — eyeballable on a phone) +// week: last 5 ISO weeks (Mon-anchored) +// month: last 4 complete months (start of current month - 4) +function backtestStart(lastActual: Date, grain: Granularity): Date { + if (grain === 'day') return subDays(lastActual, 7); + if (grain === 'week') return startOfWeek(subDays(lastActual, 35), { weekStartsOn: 1 }); + return startOfMonth(subMonths(lastActual, 4)); +} + +export const GET: RequestHandler = async ({ locals, url }) => { + const { claims } = await locals.safeGetSession(); + if (!claims) return json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE }); + + const granularity = parseGranularity(url.searchParams.get('granularity')); + if (!granularity) { + return json({ error: 'invalid granularity (must be day, week, or month)' }, { status: 400, headers: NO_STORE }); + } + + const kpiRaw = url.searchParams.get('kpi') ?? 'revenue_eur'; + if (!(KPIS as readonly string[]).includes(kpiRaw)) { + return json({ error: 'invalid kpi (must be revenue_eur or invoice_count)' }, { status: 400, headers: NO_STORE }); + } + const kpi = kpiRaw as Kpi; + + try { + // Forecast rows + sibling event tables + pipeline runs all in parallel. + // The MV holds the latest run per (target_date, model, grain) so no + // run_date filter is needed — read everything at this (kpi, grain). + const today = new Date(); + const todayStr = format(today, 'yyyy-MM-dd'); + + // Events horizon spans the longest forecast we ship. We don't know it + // yet at this point, so we use a generous one-year window — clampEvents + // trims to 50 anyway. + const eventsEnd = format(subDays(today, -365), 'yyyy-MM-dd'); + + const [forecastRows, holidayRows, schoolRows, recurRows, transitRows, pipelineRows] = await Promise.all([ + fetchAll(() => + locals.supabase + .from('forecast_with_actual_v') + .select('target_date,model_name,granularity,yhat,yhat_lower,yhat_upper,horizon_days,actual_value,forecast_track,kpi_name') + .eq('kpi_name', kpi) + .eq('forecast_track', 'bau') + .eq('granularity', granularity) + .order('target_date', { ascending: true }) + ), + fetchAll(() => + locals.supabase + .from('holidays') + .select('date,name,country_code,subdiv_code') + .gte('date', todayStr) + .lte('date', eventsEnd) + .or('subdiv_code.is.null,subdiv_code.eq.BE') + ), + fetchAll(() => + locals.supabase + .from('school_holidays') + .select('state_code,block_name,start_date,end_date') + .eq('state_code', 'BE') + .gte('start_date', todayStr) + .lte('start_date', eventsEnd) + ), + fetchAll(() => + locals.supabase + .from('recurring_events') + .select('event_id,name,start_date,end_date,impact_estimate') + .gte('start_date', todayStr) + .lte('start_date', eventsEnd) + ), + fetchAll(() => + locals.supabase + .from('transit_alerts') + .select('alert_id,title,pub_date,matched_keyword') + .gte('pub_date', todayStr) + .lte('pub_date', eventsEnd) + ), + fetchAll(() => + locals.supabase + .from('pipeline_runs_status_v') + .select('step_name,status,finished_at') + .eq('status', 'success') + .order('finished_at', { ascending: false }) + ) + ]); + + // Backtest actuals from kpi_daily_v. Anchor on the latest forecast row + // that has an actual_value (= last business day fully observed by the + // model). If forecastRows is empty (cold start), fall back to "yesterday" + // so we still ship a reasonable window. + const lastActualDate = forecastRows.reduce( + (mx, r) => (r.actual_value !== null && r.target_date > mx) ? r.target_date : mx, + '0000-01-01' + ); + const lastActual = lastActualDate === '0000-01-01' + ? subDays(today, 1) + : new Date(lastActualDate + 'T00:00:00Z'); + const btStart = format(backtestStart(lastActual, granularity), 'yyyy-MM-dd'); + + const actualsRows = await fetchAll(() => + locals.supabase + .from('kpi_daily_v') + .select('business_date,revenue_cents,tx_count') + .gte('business_date', btStart) + .order('business_date', { ascending: true }) + ); + + const actuals = actualsRows.map((r) => ({ + date: r.business_date, + value: kpi === 'revenue_eur' ? r.revenue_cents / 100 : r.tx_count + })); + + // Sibling events array — preserved verbatim from v1. + const events: ForecastEvent[] = [ + ...holidayRows.map((h) => ({ type: 'holiday' as const, date: h.date, label: h.name })), + ...schoolRows .map((s) => ({ type: 'school_holiday' as const, date: s.start_date, label: s.block_name, end_date: s.end_date })), + ...recurRows .map((r) => ({ type: 'recurring_event' as const, date: r.start_date, label: r.name })), + ...transitRows.map((t) => ({ type: 'transit_strike' as const, date: t.pub_date.slice(0, 10), label: t.title })) + ]; + + // Latest forecast pipeline run feeds last_run — preserved from v1. + // We pick max(finished_at) defensively rather than trusting .order() — + // the wrapper view can return ties and null finished_at rows for in-flight runs. + let last_run: string | null = null; + for (const p of pipelineRows) { + if (!p.finished_at) continue; + if (!(p.step_name === 'forecast_sarimax' || p.step_name.startsWith('forecast_'))) continue; + if (last_run === null || p.finished_at > last_run) last_run = p.finished_at; + } + + return json( + { + rows: forecastRows.map((r) => ({ + target_date: r.target_date, + model_name: r.model_name, + yhat_mean: r.yhat, + yhat_lower: r.yhat_lower, + yhat_upper: r.yhat_upper, + horizon_days: r.horizon_days + })), + actuals, + events: clampEvents(events, 50), + last_run, + kpi, + granularity + }, + { headers: NO_STORE } + ); + } catch (err) { + console.error('[/api/forecast]', err); + return json({ error: 'query failed' }, { status: 500, headers: NO_STORE }); + } +}; diff --git a/supabase/migrations/0057_forecast_daily_granularity.sql b/supabase/migrations/0057_forecast_daily_granularity.sql new file mode 100644 index 0000000..3c16932 --- /dev/null +++ b/supabase/migrations/0057_forecast_daily_granularity.sql @@ -0,0 +1,65 @@ +-- 0057_forecast_daily_granularity.sql +-- Phase 15 v2 D-14: per-grain forecasts. Each refresh writes 3 rows per +-- (model, target_date) — one for each (day, week, month) granularity — +-- so the chart can show a daily forecast trained at last_actual−7d AND +-- a weekly forecast trained at last_actual−5w AND a monthly forecast +-- trained at end-of-month−5mo, all from the same forecast_daily table. +-- +-- Backfill safety: ALTER ADD COLUMN with DEFAULT is O(rows) on PG12+. +-- forecast_daily currently holds Phase 14's nightly runs (all daily +-- grain), so DEFAULT 'day' produces correct historical labelling. +-- Then DROP DEFAULT so future inserts must specify granularity. + +ALTER TABLE public.forecast_daily + ADD COLUMN IF NOT EXISTS granularity text NOT NULL DEFAULT 'day' + CHECK (granularity IN ('day', 'week', 'month')); + +ALTER TABLE public.forecast_daily ALTER COLUMN granularity DROP DEFAULT; + +-- Drop + recreate PK to include granularity in the natural key. +-- Existing key was (restaurant_id, kpi_name, target_date, model_name, run_date, forecast_track). +ALTER TABLE public.forecast_daily DROP CONSTRAINT forecast_daily_pkey; +ALTER TABLE public.forecast_daily ADD PRIMARY KEY + (restaurant_id, kpi_name, target_date, model_name, granularity, run_date, forecast_track); + +-- Rebuild forecast_daily_mv with granularity in select + unique index. +DROP MATERIALIZED VIEW IF EXISTS public.forecast_daily_mv CASCADE; + +CREATE MATERIALIZED VIEW public.forecast_daily_mv AS +SELECT DISTINCT ON (restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track) + restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track, + run_date, yhat, yhat_lower, yhat_upper, horizon_days, exog_signature +FROM public.forecast_daily +ORDER BY restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track, run_date DESC; + +CREATE UNIQUE INDEX forecast_daily_mv_uq + ON public.forecast_daily_mv + (restaurant_id, kpi_name, target_date, model_name, granularity, forecast_track); + +REVOKE ALL ON public.forecast_daily_mv FROM authenticated, anon; + +-- Rebuild forecast_with_actual_v to include granularity passthrough. +-- Actual is keyed by business_date (daily-grain only); for weekly/monthly +-- forecasts the consumer (Phase 15 v2 endpoint) joins to k via target_date +-- which is already the bucket-start date for those grains, so the LEFT JOIN +-- works for daily but produces NULL actual_value for weekly/monthly rows +-- whose target_date doesn't land on a daily kpi_daily_mv row. The Phase +-- 15-11 endpoint handles that by building actuals from kpi_daily_mv directly +-- for the back-test window. +CREATE OR REPLACE VIEW public.forecast_with_actual_v AS +SELECT + f.restaurant_id, f.kpi_name, f.target_date, f.model_name, f.granularity, f.forecast_track, + f.run_date, f.yhat, f.yhat_lower, f.yhat_upper, f.horizon_days, f.exog_signature, + CASE f.kpi_name + WHEN 'revenue_eur' THEN k.revenue_cents / 100.0 + WHEN 'invoice_count' THEN k.tx_count::double precision + END AS actual_value +FROM public.forecast_daily_mv f +LEFT JOIN public.kpi_daily_mv k + -- restaurant_id in JOIN predicate is load-bearing for RLS — do not remove. + -- kpi_daily_mv has no RLS of its own; tenant isolation depends on this. + ON k.restaurant_id = f.restaurant_id + AND k.business_date = f.target_date +WHERE f.restaurant_id = (auth.jwt()->>'restaurant_id')::uuid; + +GRANT SELECT ON public.forecast_with_actual_v TO authenticated; diff --git a/tests/integration/forecast_daily_granularity.test.ts b/tests/integration/forecast_daily_granularity.test.ts new file mode 100644 index 0000000..1f0a7f4 --- /dev/null +++ b/tests/integration/forecast_daily_granularity.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { adminClient } from '../helpers/supabase'; + +// Phase 15-09 D-14: granularity column on forecast_daily. +// Verifies the migration 0057 contract behaviourally — schema shape via +// the existing test_table_columns RPC, plus insert-based proof of CHECK, +// NOT NULL, and PK uniqueness rules. PG behavioural assertions are more +// robust than pg_catalog snapshots: they catch any future change that +// loosens the constraint without leaving a CHECK in place. + +const admin = adminClient(); +const stamp = `g14-${Date.now()}`; +let tenantId: string; + +beforeAll(async () => { + const { data: r, error } = await admin + .from('restaurants') + .insert({ name: `granularity-${stamp}`, timezone: 'Europe/Berlin', slug: `g14-${crypto.randomUUID()}` }) + .select() + .single(); + if (error) throw error; + tenantId = r!.id; +}); + +afterAll(async () => { + // Order matters: forecast_daily references restaurants(id). + await admin.from('forecast_daily').delete().eq('restaurant_id', tenantId); + await admin.from('restaurants').delete().eq('id', tenantId); +}); + +describe('Phase 15-09: forecast_daily.granularity column', () => { + it('granularity column exists on forecast_daily', async () => { + const { data, error } = await admin.rpc('test_table_columns', { p_table_name: 'forecast_daily' }); + expect(error).toBeNull(); + const cols = ((data ?? []) as Array<{ column_name: string; data_type: string; is_nullable: string }>); + const granularity = cols.find((c) => c.column_name === 'granularity'); + expect(granularity).toBeDefined(); + // text type and NOT NULL — `is_nullable` is 'NO' (note: helper returns + // pg_type.typname, so 'text' is the canonical lowercase). + expect(granularity!.data_type).toBe('text'); + expect(granularity!.is_nullable).toBe('NO'); + }); + + it('CHECK constraint rejects granularity = "hourly" (out of allowed set)', async () => { + const { error } = await admin.from('forecast_daily').insert({ + restaurant_id: tenantId, + kpi_name: 'revenue_eur', + target_date: '2099-02-01', + model_name: `${stamp}-check`, + granularity: 'hourly', + run_date: '2099-02-01', + forecast_track: 'bau', + yhat: 1.0, + yhat_lower: 0.5, + yhat_upper: 1.5, + } as never); + // CHECK violation surfaces as a 400-class error from PostgREST. + expect(error).not.toBeNull(); + expect(error!.message).toMatch(/check|granularity/i); + }); + + it('NOT NULL: omitting granularity fails (DEFAULT was dropped post-backfill)', async () => { + const { error } = await admin.from('forecast_daily').insert({ + restaurant_id: tenantId, + kpi_name: 'revenue_eur', + target_date: '2099-02-02', + model_name: `${stamp}-notnull`, + // granularity intentionally omitted + run_date: '2099-02-02', + forecast_track: 'bau', + yhat: 1.0, + yhat_lower: 0.5, + yhat_upper: 1.5, + } as never); + // NOT NULL violation also surfaces as an error from PostgREST. + expect(error).not.toBeNull(); + expect(error!.message).toMatch(/null|granularity/i); + }); + + it('PK includes granularity: two rows differ only by granularity → both insert successfully', async () => { + // Phase 14's PK was 6-column; Phase 15-09's PK is 7-column with granularity. + // If granularity were NOT in the PK, the second insert would conflict on + // the natural key. If it IS in the PK (correct), both rows coexist. + const base = { + restaurant_id: tenantId, + kpi_name: 'revenue_eur', + target_date: '2099-03-01', + model_name: `${stamp}-pk`, + run_date: '2099-03-01', + forecast_track: 'bau', + yhat: 10.0, + yhat_lower: 8.0, + yhat_upper: 12.0, + }; + const { error: e1 } = await admin.from('forecast_daily').insert({ ...base, granularity: 'day' } as never); + expect(e1).toBeNull(); + const { error: e2 } = await admin.from('forecast_daily').insert({ ...base, granularity: 'week' } as never); + expect(e2).toBeNull(); + + // And inserting an exact duplicate of either row DOES still conflict — proves + // the PK is enforced; granularity widens the key but does not remove uniqueness. + const { error: dupe } = await admin.from('forecast_daily').insert({ ...base, granularity: 'day' } as never); + expect(dupe).not.toBeNull(); + }); + + it('valid grain values "day", "week", "month" all insert successfully', async () => { + const base = { + restaurant_id: tenantId, + kpi_name: 'invoice_count', + target_date: '2099-04-01', + model_name: `${stamp}-valid`, + run_date: '2099-04-01', + forecast_track: 'bau', + yhat: 5.0, + yhat_lower: 4.0, + yhat_upper: 6.0, + }; + for (const g of ['day', 'week', 'month'] as const) { + const { error } = await admin.from('forecast_daily').insert({ ...base, granularity: g } as never); + expect(error, `granularity=${g}`).toBeNull(); + } + }); + + it('forecast_with_actual_v exposes granularity column', async () => { + const { data, error } = await admin.rpc('test_table_columns', { p_table_name: 'forecast_with_actual_v' }); + expect(error).toBeNull(); + const names = ((data ?? []) as Array<{ column_name: string }>).map((c) => c.column_name); + expect(names).toContain('granularity'); + // Sanity: existing Phase 14 columns are still present after the rebuild. + expect(names).toContain('restaurant_id'); + expect(names).toContain('kpi_name'); + expect(names).toContain('target_date'); + expect(names).toContain('forecast_track'); + expect(names).toContain('actual_value'); + }); + + it('forecast_daily_mv exposes granularity column (raw MV via service_role)', async () => { + // service_role bypasses the REVOKE; this asserts the MV definition itself + // includes granularity in its select-list (consumed by 15-11 endpoint). + const { data, error } = await admin.rpc('test_table_columns', { p_table_name: 'forecast_daily_mv' }); + expect(error).toBeNull(); + const names = ((data ?? []) as Array<{ column_name: string }>).map((c) => c.column_name); + expect(names).toContain('granularity'); + expect(names).toContain('restaurant_id'); + expect(names).toContain('kpi_name'); + expect(names).toContain('target_date'); + }); + + it('backfill: pre-migration rows are labelled granularity=day', async () => { + // Plan 15-09 added the granularity column with DEFAULT 'day' before + // dropping the default. Any row that existed before migration 0057 + // ran (i.e., run_date < 2026-05-01) MUST have been backfilled to 'day'. + // After a fresh `supabase db reset` the table may be empty — that's + // fine, the property holds vacuously. The point of this test is to + // catch a future regression where someone re-orders the migration + // steps (e.g., moves DROP DEFAULT before backfill). + const { data, error } = await admin + .from('forecast_daily') + .select('granularity, run_date') + .lt('run_date', '2026-05-01'); + expect(error).toBeNull(); + for (const row of data ?? []) { + expect(row.granularity).toBe('day'); + } + }); +}); diff --git a/tests/unit/CalendarCards.test.ts b/tests/unit/CalendarCards.test.ts index 6fd1736..e9f8217 100644 --- a/tests/unit/CalendarCards.test.ts +++ b/tests/unit/CalendarCards.test.ts @@ -119,6 +119,77 @@ describe('CalendarRevenueCard (VA-04) source artifacts', () => { }); }); +// Phase 15-12: Forecast overlay artifact assertions. Live-render assertions +// can't run reliably in jsdom (LayerChart needs ResizeObserver / layout APIs); +// the e2e suite (charts-all.spec.ts) covers the visual gate. These guard the +// overlay's structural contract — toggling, data flow, scale alignment. +describe('CalendarRevenueCard (15-12) forecast overlay artifacts', () => { + const src = fs.readFileSync( + path.join(process.cwd(), 'src/lib/components/CalendarRevenueCard.svelte'), + 'utf8' + ); + + it('imports ForecastLegend + clientFetch + FORECAST_MODEL_COLORS', () => { + expect(src).toMatch(/import\s+ForecastLegend\b/); + expect(src).toMatch(/import\s*\{\s*clientFetch\s*\}/); + expect(src).toMatch(/FORECAST_MODEL_COLORS/); + }); + + it('fetches /api/forecast?kpi=revenue_eur&granularity=', () => { + expect(src).toMatch(/\/api\/forecast\?kpi=revenue_eur&granularity=/); + }); + + it('defaults visibleModels to {sarimax, naive_dow} (D-15)', () => { + expect(src).toMatch(/new\s+Set\(\s*\[\s*['"]sarimax['"]\s*,\s*['"]naive_dow['"]\s*\]/); + }); + + it('toggleModel creates a NEW Set (Svelte 5 reactivity)', () => { + // Required so $derived chains re-run; mutating the existing Set fails silently. + expect(src).toMatch(/const\s+next\s*=\s*new\s+Set\(visibleModels\)/); + }); + + it('renders Area for CI band + Spline for line per visible model', () => { + expect(src).toMatch(/ { + expect(src).toMatch(/scaleTime\s*\(\s*\)/); + expect(src).toMatch(/xInterval=\{xInterval\}/); + expect(src).toMatch(/timeDay/); + expect(src).toMatch(/timeMonday/); + expect(src).toMatch(/timeMonth/); + }); + + it('extends xDomain to today + 365d for forecast horizon', () => { + expect(src).toMatch(/addDays\(\s*new\s+Date\(\)\s*,\s*365\s*\)/); + }); + + it('naive_dow renders dashed at stroke-width=1', () => { + expect(src).toMatch(/isNaive\s*\?\s*1\s*:\s*2/); + expect(src).toMatch(/isNaive\s*\?\s*['"]4 4['"]/); + }); + + it('CI band uses fillOpacity 0.06 (back layer mush prevention)', () => { + expect(src).toMatch(/fillOpacity=\{?0\.06\}?/); + }); + + it('renders ForecastLegend chip row when forecastData present', () => { + expect(src).toMatch( + / { + expect(src).toMatch(/lastFetchedGrain/); + }); +}); + describe('CalendarCountsCard (VA-05) source artifacts', () => { const src = fs.readFileSync( path.join(process.cwd(), 'src/lib/components/CalendarCountsCard.svelte'), @@ -156,3 +227,84 @@ describe('CalendarCountsCard (VA-05) source artifacts', () => { expect(src).toMatch(/bucketTrend/); }); }); + +// Phase 15-13: Forecast overlay artifact assertions for CalendarCountsCard. +// Sister of the 15-12 CalendarRevenueCard suite — same overlay contract but +// against the invoice_count KPI. Live render skipped (jsdom can't), e2e in +// charts-all.spec.ts gate. These guard the structural contract. +describe('CalendarCountsCard (15-13) forecast overlay artifacts', () => { + const src = fs.readFileSync( + path.join(process.cwd(), 'src/lib/components/CalendarCountsCard.svelte'), + 'utf8' + ); + + it('imports ForecastLegend + clientFetch + FORECAST_MODEL_COLORS', () => { + expect(src).toMatch(/import\s+ForecastLegend\b/); + expect(src).toMatch(/import\s*\{\s*clientFetch\s*\}/); + expect(src).toMatch(/FORECAST_MODEL_COLORS/); + }); + + it('fetches /api/forecast?kpi=invoice_count&granularity= (NOT revenue_eur)', () => { + expect(src).toMatch(/\/api\/forecast\?kpi=invoice_count&granularity=/); + expect(src).not.toMatch(/\/api\/forecast\?kpi=revenue_eur&granularity=/); + }); + + it('defaults visibleModels to {sarimax, naive_dow} (D-15)', () => { + expect(src).toMatch(/new\s+Set\(\s*\[\s*['"]sarimax['"]\s*,\s*['"]naive_dow['"]\s*\]/); + }); + + it('toggleModel creates a NEW Set (Svelte 5 reactivity)', () => { + // Required so $derived chains re-run; mutating the existing Set fails silently. + expect(src).toMatch(/const\s+next\s*=\s*new\s+Set\(visibleModels\)/); + }); + + it('renders Area for CI band + Spline for line per visible model', () => { + expect(src).toMatch(/ { + expect(src).toMatch(/scaleTime\s*\(\s*\)/); + expect(src).toMatch(/xInterval=\{xInterval\}/); + expect(src).toMatch(/timeDay/); + expect(src).toMatch(/timeMonday/); + expect(src).toMatch(/timeMonth/); + }); + + it('extends xDomain to today + 365d for forecast horizon', () => { + expect(src).toMatch(/addDays\(\s*new\s+Date\(\)\s*,\s*365\s*\)/); + }); + + it('naive_dow renders dashed at stroke-width=1', () => { + expect(src).toMatch(/isNaive\s*\?\s*1\s*:\s*2/); + expect(src).toMatch(/isNaive\s*\?\s*['"]4 4['"]/); + }); + + it('CI band uses fillOpacity 0.06 (back layer mush prevention)', () => { + expect(src).toMatch(/fillOpacity=\{?0\.06\}?/); + }); + + it('renders ForecastLegend chip row when forecastData present', () => { + expect(src).toMatch( + / { + expect(src).toMatch(/lastFetchedGrain/); + }); + + it('renders yhat_mean directly (NO /100 divisor — invoice_count is integer COUNT)', () => { + // Critical KPI scaling rule: revenue_cents bars divide by /100 for EUR rendering, + // but invoice_count is already an integer count. Yhat values from /api/forecast + // come through unchanged. A stray /100 here would shrink the forecast 100x. + expect(src).not.toMatch(/yhat_mean[^}]*\/\s*100/); + expect(src).not.toMatch(/yhat_lower[^}]*\/\s*100/); + expect(src).not.toMatch(/yhat_upper[^}]*\/\s*100/); + }); +}); diff --git a/tests/unit/EventMarker.test.ts b/tests/unit/EventMarker.test.ts new file mode 100644 index 0000000..694079e --- /dev/null +++ b/tests/unit/EventMarker.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +// tests/unit/EventMarker.test.ts +// Phase 15 D-09 / FUI-05 — EventMarker renders 5 event types as SVG primitives. +// We pass a fake xScale (linear function) so the component can compute x positions +// without needing a real parent. +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/svelte'; +import EventMarker from '../../src/lib/components/EventMarker.svelte'; +import type { ForecastEvent } from '../../src/lib/forecastEventClamp'; + +// Vitest config has no `globals: true`, so @testing-library/svelte's auto +// afterEach cleanup is not registered. Call it explicitly so each test +// renders a fresh DOM (otherwise multiple renders pile up and the same-host +// queries find duplicate matches). +afterEach(() => { + cleanup(); +}); + +beforeAll(() => { + if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + } +}); + +// Identity-ish xScale: maps date string -> x pixel by parsing YYYY-MM-DD's day-of-month. +// Sufficient for the test - we only assert primitive count + class / data-type, not x exactness. +const xScale = (dateStr: string | Date): number => { + const s = typeof dateStr === 'string' ? dateStr : dateStr.toISOString().slice(0, 10); + return Number(s.slice(8, 10)); +}; + +function renderInSvg(events: ForecastEvent[]) { + // Wrap in via document.body so the component's path/line/rect children + // attach to a real SVG host (correct namespace). + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '100'); + svg.setAttribute('height', '100'); + document.body.appendChild(svg); + return render(EventMarker, { + target: svg, + props: { events, xScale, height: 100 } + }); +} + +describe('EventMarker', () => { + it('renders one with stroke=red for campaign_start', () => { + const events: ForecastEvent[] = [ + { type: 'campaign_start', date: '2026-04-14', label: 'Spring' } + ]; + const { container } = renderInSvg(events); + const lines = container.querySelectorAll('line[data-event-type="campaign_start"]'); + expect(lines.length).toBe(1); + }); + + it('renders one dashed for each holiday', () => { + const events: ForecastEvent[] = [ + { type: 'holiday', date: '2026-05-01', label: 'Tag der Arbeit' }, + { type: 'holiday', date: '2026-05-08', label: 'Liberation Day' } + ]; + const { container } = renderInSvg(events); + const lines = container.querySelectorAll('line[data-event-type="holiday"]'); + expect(lines.length).toBe(2); + for (const l of lines) { + expect(l.getAttribute('stroke-dasharray')).toBeTruthy(); + } + }); + + it('renders background spanning start->end for school_holiday', () => { + const events: ForecastEvent[] = [ + { type: 'school_holiday', date: '2026-07-09', end_date: '2026-08-22', label: 'Sommerferien' } + ]; + const { container } = renderInSvg(events); + const rects = container.querySelectorAll('rect[data-event-type="school_holiday"]'); + expect(rects.length).toBe(1); + // Width = (end_date - start_date) in xScale units. Our test scale uses + // day-of-month; just assert > 0 so cross-month inputs still satisfy. + const width = Number(rects[0].getAttribute('width')); + expect(width).toBeGreaterThan(0); + }); + + it('renders one yellow for recurring_event', () => { + const events: ForecastEvent[] = [ + { type: 'recurring_event', date: '2026-09-26', label: 'Berlin Marathon' } + ]; + const { container } = renderInSvg(events); + const lines = container.querySelectorAll('line[data-event-type="recurring_event"]'); + expect(lines.length).toBe(1); + }); + + it('renders a top-of-chart 4px bar for transit_strike', () => { + const events: ForecastEvent[] = [ + { type: 'transit_strike', date: '2026-05-02', label: 'BVG Warnstreik' } + ]; + const { container } = renderInSvg(events); + const rects = container.querySelectorAll('rect[data-event-type="transit_strike"]'); + expect(rects.length).toBe(1); + expect(Number(rects[0].getAttribute('height'))).toBe(4); + }); + + it('mixed events array renders all 5 types simultaneously', () => { + const events: ForecastEvent[] = [ + { type: 'campaign_start', date: '2026-04-14', label: 'Spring' }, + { type: 'holiday', date: '2026-05-01', label: 'Tag der Arbeit' }, + { type: 'school_holiday', date: '2026-07-09', end_date: '2026-08-22', label: 'Sommerferien' }, + { type: 'recurring_event', date: '2026-09-26', label: 'Berlin Marathon' }, + { type: 'transit_strike', date: '2026-05-02', label: 'BVG Warnstreik' } + ]; + const { container } = renderInSvg(events); + expect(container.querySelectorAll('[data-event-type="campaign_start"]').length).toBe(1); + expect(container.querySelectorAll('[data-event-type="holiday"]').length).toBe(1); + expect(container.querySelectorAll('[data-event-type="school_holiday"]').length).toBe(1); + expect(container.querySelectorAll('[data-event-type="recurring_event"]').length).toBe(1); + expect(container.querySelectorAll('[data-event-type="transit_strike"]').length).toBe(1); + }); + + it('renders empty (no nodes) when events array is empty', () => { + const { container } = renderInSvg([]); + expect(container.querySelectorAll('[data-event-type]').length).toBe(0); + }); +}); diff --git a/tests/unit/ForecastHoverPopup.test.ts b/tests/unit/ForecastHoverPopup.test.ts new file mode 100644 index 0000000..a0ed216 --- /dev/null +++ b/tests/unit/ForecastHoverPopup.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +// tests/unit/ForecastHoverPopup.test.ts +// Phase 15 FUI-04 — popup body renders 6 fields. Falls back to empty-state copy +// for the accuracy fields when forecast_quality has no rows yet. +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/svelte'; +import ForecastHoverPopup from '../../src/lib/components/ForecastHoverPopup.svelte'; + +// Vitest config has no `globals: true`, so @testing-library/svelte's auto +// afterEach cleanup is not registered. Call it explicitly so each test +// renders a fresh DOM. +afterEach(() => { + cleanup(); +}); + +beforeAll(() => { + if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + } +}); + +const QUALITY = new Map([ + ['sarimax|7', { rmse: 142.31, mape: 0.084, mean_bias: 12.5, direction_hit_rate: 0.71 }] +]); + +describe('ForecastHoverPopup', () => { + it('renders forecast value + 95% CI for the hovered row', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', + model_name: 'sarimax', + yhat_mean: 1234.56, + yhat_lower: 1100, + yhat_upper: 1380, + horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: -432.10, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-forecast-value').textContent).toMatch(/1\.234|1.235/); // de-DE rounding tolerance + expect(getByTestId('popup-ci-low-high').textContent).toContain('1.100'); + expect(getByTestId('popup-ci-low-high').textContent).toContain('1.380'); + }); + + it('renders horizon as "7 days from today"', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234.56, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: 0, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-horizon').textContent).toMatch(/7 days from today/); + }); + + it('renders horizon as "1 day from today" (singular) for horizon_days=1', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-02', model_name: 'sarimax', + yhat_mean: 1234, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 1 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: 0, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-horizon').textContent).toMatch(/1 day from today/); + }); + + it('renders 4 quality metrics when forecast_quality row exists for (model, horizon)', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234.56, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: -432.10, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-rmse').textContent).toMatch(/142|142,31/); + expect(getByTestId('popup-mape').textContent).toMatch(/8\.4|8,4/); + expect(getByTestId('popup-bias').textContent).toMatch(/12|12,5/); + expect(getByTestId('popup-direction-hit').textContent).toMatch(/71/); + }); + + it('renders the "Accuracy data builds after first nightly run" empty state when no quality row exists', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'prophet', + yhat_mean: 1100, yhat_lower: 980, yhat_upper: 1220, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, // only sarimax|7 — prophet missing + cumulativeDeviationEur: 0, + lastRun: '2026-05-01T01:34:22Z' + }); + const empty = getByTestId('popup-quality-empty'); + // Copy is the empty_forecast_quality_empty_body key shipped in Phase 15-01 + // (commit 9fc6e68). Test asserts the exact i18n value. + expect(empty.textContent).toMatch(/Forecast accuracy metrics need at least one completed nightly evaluation cycle/); + }); + + it('renders cumulative deviation since campaign with EUR formatting', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: -432.10, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-uplift').textContent).toMatch(/-432|-43.210|−432/); // sign + EUR + }); + + it('renders "Last refit {ago} ago" when lastRun present', () => { + const { getByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: 0, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(getByTestId('popup-last-refit').textContent).toMatch(/Last refit/); + expect(getByTestId('popup-last-refit').textContent).toMatch(/ago/); + }); + + it('omits the last-refit field when lastRun is null', () => { + const { queryByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234, yhat_lower: 1380, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: 0, + lastRun: null + }); + expect(queryByTestId('popup-last-refit')).toBeNull(); + }); + + it('omits cumulative deviation field when cumulativeDeviationEur is null (endpoint failed)', () => { + const { queryByTestId } = render(ForecastHoverPopup, { + hoveredRow: { + target_date: '2026-05-08', model_name: 'sarimax', + yhat_mean: 1234, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 7 + }, + qualityByModelHorizon: QUALITY, + cumulativeDeviationEur: null, + lastRun: '2026-05-01T01:34:22Z' + }); + expect(queryByTestId('popup-uplift')).toBeNull(); + }); +}); diff --git a/tests/unit/ForecastLegend.test.ts b/tests/unit/ForecastLegend.test.ts new file mode 100644 index 0000000..b41f98b --- /dev/null +++ b/tests/unit/ForecastLegend.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +// tests/unit/ForecastLegend.test.ts +// Phase 15 D-04 / FUI-02 — chip row, default visibleModels = {sarimax, naive_dow}. +// Disabled state for models not present in availableModels (feature-flag off). +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, fireEvent, cleanup } from '@testing-library/svelte'; +import ForecastLegend from '../../src/lib/components/ForecastLegend.svelte'; + +// Vitest config has no `globals: true`, so @testing-library/svelte's auto +// afterEach cleanup is not registered. Call it explicitly so each test +// renders a fresh DOM (otherwise multiple renders pile up and getByRole +// finds duplicate matches). +afterEach(() => { + cleanup(); +}); + +beforeAll(() => { + if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + } +}); + +const ALL_FIVE_BAU = ['sarimax', 'prophet', 'ets', 'theta', 'naive_dow']; + +describe('ForecastLegend', () => { + it('renders one chip per FORECAST_MODEL_COLORS palette entry', () => { + const { getAllByRole } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax', 'naive_dow']), + ontoggle: () => {} + }); + // 7 palette entries — 5 BAU + 2 feature-flagged + expect(getAllByRole('button').length).toBe(7); + }); + + it('default visible chips render aria-pressed=true; hidden chips render aria-pressed=false', () => { + const { getByRole } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax', 'naive_dow']), + ontoggle: () => {} + }); + expect(getByRole('button', { name: /SARIMAX/ })).toHaveAttribute('aria-pressed', 'true'); + expect(getByRole('button', { name: /Naive/ })).toHaveAttribute('aria-pressed', 'true'); + // /Prophet/ would also match "NeuralProphet" — anchor to distinguish. + expect(getByRole('button', { name: /^Prophet$/ })).toHaveAttribute('aria-pressed', 'false'); + expect(getByRole('button', { name: /ETS/ })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('clicking a chip fires ontoggle(modelName)', async () => { + const spy = vi.fn(); + const { getByRole } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax', 'naive_dow']), + ontoggle: spy + }); + // Anchor regex — /Prophet/ alone matches both Prophet and NeuralProphet. + await fireEvent.click(getByRole('button', { name: /^Prophet$/ })); + expect(spy).toHaveBeenCalledWith('prophet'); + }); + + it('models NOT in availableModels render disabled (aria-disabled=true) and do not fire ontoggle', async () => { + const spy = vi.fn(); + const { getByRole } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, // chronos + neuralprophet absent + visibleModels: new Set(['sarimax', 'naive_dow']), + ontoggle: spy + }); + const chronosChip = getByRole('button', { name: /Chronos/ }); + expect(chronosChip).toHaveAttribute('aria-disabled', 'true'); + await fireEvent.click(chronosChip); + expect(spy).not.toHaveBeenCalled(); + }); + + it('disabled chips render at 40% opacity (className includes opacity-40)', () => { + const { getByRole } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax', 'naive_dow']), + ontoggle: () => {} + }); + const chronosChip = getByRole('button', { name: /Chronos/ }); + expect(chronosChip.className).toMatch(/opacity-40/); + }); + + it('chip dot color matches FORECAST_MODEL_COLORS for that model', () => { + const { container } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax']), + ontoggle: () => {} + }); + // SARIMAX dot uses inline style background-color = #4e79a7 (schemeTableau10[0]). + // JSDOM normalises inline hex into rgb() form when it serialises style, + // so accept either notation — the source-of-truth is the component's + // string template; both representations are byte-equivalent CSS values. + const sarimaxBtn = container.querySelector('[data-model="sarimax"]'); + const dot = sarimaxBtn?.querySelector('[data-testid="legend-dot"]'); + const style = dot?.getAttribute('style') ?? ''; + expect(style).toMatch(/#4e79a7|rgb\(\s*78\s*,\s*121\s*,\s*167\s*\)/i); + }); + + it('container is a horizontal-scroll row (overflow-x-auto)', () => { + const { container } = render(ForecastLegend, { + availableModels: ALL_FIVE_BAU, + visibleModels: new Set(['sarimax']), + ontoggle: () => {} + }); + const row = container.querySelector('[data-testid="forecast-legend"]'); + expect(row?.className ?? '').toMatch(/overflow-x-auto/); + }); +}); diff --git a/tests/unit/InvoiceCountForecastCard.test.ts b/tests/unit/InvoiceCountForecastCard.test.ts new file mode 100644 index 0000000..7c82b26 --- /dev/null +++ b/tests/unit/InvoiceCountForecastCard.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +// tests/unit/InvoiceCountForecastCard.test.ts +// Phase 15-15 — composition test for the invoice_count sibling card. +// Mirrors RevenueForecastCard.test.ts (15-14): the card self-fetches forecast +// data on grain change via clientFetch. We stub clientFetch so the $effect +// resolves a fixture payload synchronously inside the render call. Visual +// fidelity (axis ticks, band opacity) is verified at the localhost / DEV gate. +import { describe, it, expect, beforeAll, vi, afterEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/svelte'; + +// Stub clientFetch BEFORE importing the component — Vite hoists vi.mock() +// calls to the top so the mock is in place at module load. Use vi.hoisted() +// so the spy ref is available inside the hoisted factory. We assert on the +// URL inside the spy so we know the card hits ?kpi=invoice_count specifically. +const { clientFetchSpy } = vi.hoisted(() => ({ + clientFetchSpy: vi.fn(async (url: string) => { + if (!url.includes('kpi=invoice_count')) { + throw new Error(`Expected kpi=invoice_count in URL, got: ${url}`); + } + return FORECAST_PAYLOAD_HOISTED; + }) +})); +const { FORECAST_PAYLOAD_HOISTED } = vi.hoisted(() => ({ + FORECAST_PAYLOAD_HOISTED: { + rows: [ + { target_date: '2026-05-01', model_name: 'sarimax', yhat_mean: 87, yhat_lower: 78, yhat_upper: 96, horizon_days: 1 }, + { target_date: '2026-05-02', model_name: 'sarimax', yhat_mean: 92, yhat_lower: 83, yhat_upper: 101, horizon_days: 2 }, + { target_date: '2026-05-01', model_name: 'naive_dow', yhat_mean: 84, yhat_lower: 84, yhat_upper: 84, horizon_days: 1 }, + { target_date: '2026-05-02', model_name: 'naive_dow', yhat_mean: 89, yhat_lower: 89, yhat_upper: 89, horizon_days: 2 } + ], + actuals: [ + { date: '2026-04-29', value: 81 }, + { date: '2026-04-30', value: 86 } + ], + events: [], + last_run: '2026-04-30T01:34:22Z', + kpi: 'invoice_count', + granularity: 'day' + } +})); +vi.mock('$lib/clientFetch', () => ({ + clientFetch: clientFetchSpy +})); + +// Stub the dashboard store so getFilters().grain returns a stable value. +// Importing dashboardStore.svelte from a unit test requires the runes runtime; +// the mock keeps the test boundary clean. +vi.mock('$lib/dashboardStore.svelte', () => ({ + getFilters: () => ({ grain: 'day' }) +})); + +import InvoiceCountForecastCard from '../../src/lib/components/InvoiceCountForecastCard.svelte'; + +// vite.config.ts does not set globals: true, so testing-library's auto-cleanup +// hook does not register. Without this afterEach, subsequent render() calls +// pile up on the same JSDOM body and getByRole returns "Found multiple elements" +// errors. +afterEach(() => cleanup()); + +beforeAll(() => { + if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + } + if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) { + // @ts-expect-error — test stub + window.IntersectionObserver = class { + observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } + }; + } +}); + +// Microtask flush helper — gives the $effect's clientFetch promise a tick +// to resolve so the fixture lands in forecastData and the chart renders. +async function flush() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('InvoiceCountForecastCard (15-15 sibling)', () => { + it('renders the card shell with invoice count title + description', () => { + const { container } = render(InvoiceCountForecastCard); + expect(container.querySelector('[data-testid="invoice-forecast-card"]')).toBeInTheDocument(); + expect(container.textContent).toMatch(/Invoice count forecast/); + }); + + it('renders the EmptyState before forecast data resolves', () => { + const { container } = render(InvoiceCountForecastCard); + // First paint, before the awaited clientFetch microtask flush. + expect(container.textContent).toMatch(/Forecast generating|Check back tomorrow/); + }); + + it('renders ForecastLegend after fixture payload resolves', async () => { + const { container } = render(InvoiceCountForecastCard); + await flush(); + expect(container.querySelector('[data-testid="forecast-legend"]')).toBeInTheDocument(); + }); + + it('renders Spline / Area elements for each visible model (invoice count)', async () => { + const { container } = render(InvoiceCountForecastCard); + await flush(); + // LayerChart Spline + Area both emit elements; CI band uses + // Area (closed path), forecast lines use Spline (open path). With both + // sarimax + naive_dow visible by default we expect ≥ 4 paths + // (2 areas + 2 lines), plus the actuals overlay path = ≥ 5. + const paths = container.querySelectorAll('svg path'); + expect(paths.length).toBeGreaterThanOrEqual(4); + }); + + it('does NOT render a HorizonToggle (15-15 mirrors 15-14 — dropped)', async () => { + const { container } = render(InvoiceCountForecastCard); + await flush(); + const groups = container.querySelectorAll('[role="group"]'); + const horizonGroup = Array.from(groups).find(g => + (g.getAttribute('aria-label') ?? '').toLowerCase().includes('horizon') + ); + expect(horizonGroup).toBeUndefined(); + }); + + it('fetches /api/forecast with kpi=invoice_count', async () => { + render(InvoiceCountForecastCard); + await flush(); + // The clientFetch spy throws if the URL is wrong; this assertion + // confirms it was called at least once. + expect(clientFetchSpy).toHaveBeenCalled(); + const calledWith = clientFetchSpy.mock.calls.map(c => c[0] as string); + expect(calledWith.some(u => u.includes('kpi=invoice_count'))).toBe(true); + }); +}); diff --git a/tests/unit/RevenueForecastCard.test.ts b/tests/unit/RevenueForecastCard.test.ts new file mode 100644 index 0000000..82a5bc6 --- /dev/null +++ b/tests/unit/RevenueForecastCard.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +// tests/unit/RevenueForecastCard.test.ts +// Phase 15-14 — composition test. The card now self-fetches forecast data +// on grain change via clientFetch (no horizon prop, no granularity prop). +// We stub clientFetch so the $effect resolves a fixture payload synchronously +// inside the render call. Visual fidelity (axis ticks, band opacity) is +// verified at the localhost gate, not here. +import { describe, it, expect, beforeAll, vi, afterEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/svelte'; + +// Stub clientFetch BEFORE importing the component — Vite hoists vi.mock() +// calls to the top so the mock is in place at module load. +vi.mock('$lib/clientFetch', () => ({ + clientFetch: vi.fn(async () => FORECAST_PAYLOAD) +})); + +// Stub the dashboard store so getFilters().grain returns a stable value. +// Importing dashboardStore.svelte from a unit test requires the runes runtime; +// the mock keeps the test boundary clean. +vi.mock('$lib/dashboardStore.svelte', () => ({ + getFilters: () => ({ grain: 'day' }) +})); + +import RevenueForecastCard from '../../src/lib/components/RevenueForecastCard.svelte'; + +// vite.config.ts does not set globals: true, so testing-library's auto-cleanup +// hook does not register. Without this afterEach, subsequent render() calls +// pile up on the same JSDOM body and getByRole returns "Found multiple elements" +// errors. +afterEach(() => cleanup()); + +beforeAll(() => { + if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((q: string) => ({ + matches: false, media: q, onchange: null, + addListener: vi.fn(), removeListener: vi.fn(), + addEventListener: vi.fn(), removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + } + if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) { + // @ts-expect-error — test stub + window.IntersectionObserver = class { + observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } + }; + } +}); + +const FORECAST_PAYLOAD = { + rows: [ + { target_date: '2026-05-01', model_name: 'sarimax', yhat_mean: 1234.56, yhat_lower: 1100, yhat_upper: 1380, horizon_days: 1 }, + { target_date: '2026-05-02', model_name: 'sarimax', yhat_mean: 1300, yhat_lower: 1170, yhat_upper: 1430, horizon_days: 2 }, + { target_date: '2026-05-01', model_name: 'naive_dow', yhat_mean: 1200, yhat_lower: 1200, yhat_upper: 1200, horizon_days: 1 }, + { target_date: '2026-05-02', model_name: 'naive_dow', yhat_mean: 1250, yhat_lower: 1250, yhat_upper: 1250, horizon_days: 2 } + ], + actuals: [ + { date: '2026-04-29', value: 1180 }, + { date: '2026-04-30', value: 1220 } + ], + events: [], + last_run: '2026-04-30T01:34:22Z', + kpi: 'revenue_eur', + granularity: 'day' +}; + +// Microtask flush helper — gives the $effect's clientFetch promise a tick +// to resolve so the fixture lands in forecastData and the chart renders. +async function flush() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('RevenueForecastCard (15-14 rewrite)', () => { + it('renders the card shell with title + description', () => { + const { container } = render(RevenueForecastCard); + expect(container.querySelector('[data-testid="revenue-forecast-card"]')).toBeInTheDocument(); + expect(container.textContent).toMatch(/Revenue forecast/); + }); + + it('renders the EmptyState before forecast data resolves', () => { + const { container } = render(RevenueForecastCard); + // First paint, before the awaited clientFetch microtask flush. + expect(container.textContent).toMatch(/Forecast generating|Check back tomorrow/); + }); + + it('renders ForecastLegend after fixture payload resolves', async () => { + const { container } = render(RevenueForecastCard); + await flush(); + expect(container.querySelector('[data-testid="forecast-legend"]')).toBeInTheDocument(); + }); + + it('renders Spline / Area elements for each visible model', async () => { + const { container } = render(RevenueForecastCard); + await flush(); + // LayerChart Spline + Area both emit elements; CI band uses + // Area (closed path), forecast lines use Spline (open path). With both + // sarimax + naive_dow visible by default we expect ≥ 4 paths + // (2 areas + 2 lines), plus the actuals overlay path = ≥ 5. + const paths = container.querySelectorAll('svg path'); + expect(paths.length).toBeGreaterThanOrEqual(4); + }); + + it('does NOT render a HorizonToggle (15-14 dropped it)', async () => { + const { container } = render(RevenueForecastCard); + await flush(); + const groups = container.querySelectorAll('[role="group"]'); + const horizonGroup = Array.from(groups).find(g => + (g.getAttribute('aria-label') ?? '').toLowerCase().includes('horizon') + ); + expect(horizonGroup).toBeUndefined(); + }); +}); diff --git a/tests/unit/apiEndpoints.test.ts b/tests/unit/apiEndpoints.test.ts index 6081ba9..d88bb99 100644 --- a/tests/unit/apiEndpoints.test.ts +++ b/tests/unit/apiEndpoints.test.ts @@ -405,3 +405,376 @@ describe('/api/repeater-lifetime', () => { expect(state.fromSpy).not.toHaveBeenCalled(); }); }); + +// -------------------- /api/forecast -------------------- +// Phase 15 v2 D-14: endpoint queries native-grain rows from forecast_with_actual_v +// (no resampling) and ships a backtest-window slice from kpi_daily_v alongside. +// ?kpi= picks the KPI; ?granularity= picks the grain. Horizon is implicit in +// the row set (one model run per grain per refresh). +import { GET as forecastGET } from '../../src/routes/api/forecast/+server'; + +describe('/api/forecast', () => { + const fcastRow = { + target_date: '2026-05-01', + model_name: 'sarimax', + granularity: 'day', + yhat: 1234.56, + yhat_lower: 1100, + yhat_upper: 1380, + horizon_days: 1, + actual_value: null, + forecast_track: 'bau', + kpi_name: 'revenue_eur' + }; + const fcastRowWithActual = { + ...fcastRow, + target_date: '2026-04-29', + actual_value: 1500 + }; + const fcastRowInvoiceCount = { + ...fcastRow, + yhat: 42, + yhat_lower: 35, + yhat_upper: 50, + kpi_name: 'invoice_count' + }; + const fcastRowWeek = { ...fcastRow, target_date: '2026-04-27', granularity: 'week' }; + const dailyKpiRow = { business_date: '2026-04-29', revenue_cents: 150000, tx_count: 42 }; + const holidayRow = { date: '2026-05-01', name: 'Tag der Arbeit', country_code: 'DE', subdiv_code: null }; + const schoolRow = { state_code: 'BE', block_name: 'Sommerferien', start_date: '2026-07-09', end_date: '2026-08-22', year: 2026 }; + const recurRow = { event_id: 'berlin-marathon-2026', name: 'Berlin Marathon', start_date: '2026-09-26', end_date: '2026-09-26', impact_estimate: 'high' }; + const transitRow = { alert_id: 'a1', title: 'BVG Warnstreik', pub_date: '2026-05-02T06:00:00Z', matched_keyword: 'Warnstreik', source_url: 'https://x' }; + const pipeRow = { step_name: 'forecast_sarimax', status: 'success', finished_at: '2026-05-01T01:34:22Z' }; + + it('authenticated GET ?granularity=day returns 200 with rows + actuals + events + last_run + kpi + granularity', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRow], + kpi_daily_v: [dailyKpiRow], + holidays: [holidayRow], + school_holidays: [schoolRow], + recurring_events: [recurRow], + transit_alerts: [transitRow], + pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.rows)).toBe(true); + expect(Array.isArray(body.actuals)).toBe(true); + expect(Array.isArray(body.events)).toBe(true); + expect(typeof body.last_run).toBe('string'); + expect(body.kpi).toBe('revenue_eur'); + expect(body.granularity).toBe('day'); + expect(body.rows[0]).toMatchObject({ + target_date: '2026-05-01', + model_name: 'sarimax', + yhat_mean: 1234.56, + yhat_lower: 1100, + yhat_upper: 1380, + horizon_days: 1 + }); + // Forecast rows MUST NOT carry actual_value — the separate actuals[] + // array owns that data. + expect('actual_value' in body.rows[0]).toBe(false); + // No `horizon` field in the response (15-11 dropped horizon-clamp). + expect('horizon' in body).toBe(false); + }); + + it('?kpi=invoice_count filters forecast_with_actual_v on kpi_name="invoice_count"', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRowInvoiceCount], + kpi_daily_v: [dailyKpiRow], + holidays: [], school_holidays: [], recurring_events: [], transit_alerts: [], + pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day&kpi=invoice_count')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.kpi).toBe('invoice_count'); + // Verify the eq('kpi_name', 'invoice_count') call landed on the forecast view. + const fcastQuery = state.queries.find((q) => q.table === 'forecast_with_actual_v'); + expect(fcastQuery).toBeDefined(); + const eqCalls = fcastQuery!.calls.filter((c) => c.method === 'eq'); + const kpiEq = eqCalls.find((c) => c.args[0] === 'kpi_name'); + expect(kpiEq?.args[1]).toBe('invoice_count'); + // And actuals come from tx_count (not revenue_cents/100) for invoice_count. + expect(body.actuals[0]).toEqual({ date: '2026-04-29', value: 42 }); + }); + + it('?granularity=week filters forecast_with_actual_v on granularity="week"', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRowWeek], + kpi_daily_v: [dailyKpiRow], + holidays: [], school_holidays: [], recurring_events: [], transit_alerts: [], + pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=week')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.granularity).toBe('week'); + const fcastQuery = state.queries.find((q) => q.table === 'forecast_with_actual_v'); + const grainEq = fcastQuery!.calls.filter((c) => c.method === 'eq').find((c) => c.args[0] === 'granularity'); + expect(grainEq?.args[1]).toBe('week'); + }); + + it('queries kpi_daily_v for the backtest window with gte(business_date, btStart)', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRowWithActual], + kpi_daily_v: [dailyKpiRow], + holidays: [], school_holidays: [], recurring_events: [], transit_alerts: [], + pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); + expect(res.status).toBe(200); + const body = await res.json(); + // kpi_daily_v query fired with a gte on business_date. + const kpiQuery = state.queries.find((q) => q.table === 'kpi_daily_v'); + expect(kpiQuery).toBeDefined(); + const gteCall = kpiQuery!.calls.find((c) => c.method === 'gte' && c.args[0] === 'business_date'); + expect(gteCall).toBeDefined(); + // For day granularity anchored on 2026-04-29, btStart = 2026-04-22. + expect(gteCall!.args[1]).toBe('2026-04-22'); + // Actuals shape: revenue_cents / 100 for revenue_eur. + expect(body.actuals[0]).toEqual({ date: '2026-04-29', value: 1500 }); + }); + + it('null claims returns 401 and never touches supabase', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsUnauthed(state), 'http://x/?granularity=day')); + expect(res.status).toBe(401); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('200 response carries Cache-Control: private, no-store', async () => { + const state = freshState({ + forecast_with_actual_v: [], kpi_daily_v: [], holidays: [], school_holidays: [], + recurring_events: [], transit_alerts: [], pipeline_runs_status_v: [] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('missing granularity returns 400', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/')); + expect(res.status).toBe(400); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('invalid granularity returns 400', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=hour')); + expect(res.status).toBe(400); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('invalid kpi returns 400', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day&kpi=evil')); + expect(res.status).toBe(400); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('events array carries holidays, school_holidays (start row), recurring, transit_strikes', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRow], + kpi_daily_v: [dailyKpiRow], + holidays: [holidayRow], + school_holidays: [schoolRow], + recurring_events: [recurRow], + transit_alerts: [transitRow], + pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=week')); + const body = await res.json(); + const types = body.events.map((e: { type: string }) => e.type).sort(); + expect(types).toContain('holiday'); + expect(types).toContain('school_holiday'); + expect(types).toContain('recurring_event'); + expect(types).toContain('transit_strike'); + }); + + it('last_run is the finished_at of the latest forecast_sarimax pipeline_runs row', async () => { + const state = freshState({ + forecast_with_actual_v: [], kpi_daily_v: [], holidays: [], school_holidays: [], + recurring_events: [], transit_alerts: [], + pipeline_runs_status_v: [ + { step_name: 'forecast_sarimax', status: 'success', finished_at: '2026-04-30T01:00:00Z' }, + { step_name: 'forecast_sarimax', status: 'success', finished_at: '2026-05-01T01:34:22Z' } + ] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); + const body = await res.json(); + expect(body.last_run).toBe('2026-05-01T01:34:22Z'); + }); + + it('supabase error on forecast_with_actual_v surfaces as 500', async () => { + const state = freshState(); + state.errors.set('forecast_with_actual_v', { message: 'boom' }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); + expect(res.status).toBe(500); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); +}); + +// -------------------- /api/forecast-quality -------------------- +import { GET as forecastQualityGET } from '../../src/routes/api/forecast-quality/+server'; + +describe('/api/forecast-quality', () => { + const qRow = { + model_name: 'sarimax', + kpi_name: 'revenue_eur', + horizon_days: 7, + rmse: 142.31, + mape: 0.084, + mean_bias: 12.5, + direction_hit_rate: 0.71, + evaluated_at: '2026-04-30T01:35:00Z', + evaluation_window: 'last_7_days' + }; + + it('authenticated GET returns 200 + array of ForecastQualityRow filtered to last_7_days', async () => { + const state = freshState({ forecast_quality: [qRow] }); + const res = await forecastQualityGET(mkEvent(mkLocalsAuthed(state))); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).toMatchObject({ + model_name: 'sarimax', + kpi_name: 'revenue_eur', + horizon_days: 7, + rmse: 142.31, + mape: 0.084, + mean_bias: 12.5, + direction_hit_rate: 0.71 + }); + }); + + it('null claims returns 401 and never touches supabase', async () => { + const state = freshState(); + const res = await forecastQualityGET(mkEvent(mkLocalsUnauthed(state))); + expect(res.status).toBe(401); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('200 response carries Cache-Control: private, no-store', async () => { + const state = freshState({ forecast_quality: [] }); + const res = await forecastQualityGET(mkEvent(mkLocalsAuthed(state))); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('supabase error surfaces as 500', async () => { + const state = freshState(); + state.errors.set('forecast_quality', { message: 'boom' }); + const res = await forecastQualityGET(mkEvent(mkLocalsAuthed(state))); + expect(res.status).toBe(500); + }); + + it('handler applies eq("evaluation_window", "last_7_days") so Phase 17 backtest rows are excluded', async () => { + // The mock records every call to .eq() — we assert the handler asked for the right filter. + const state = freshState({ forecast_quality: [qRow] }); + await forecastQualityGET(mkEvent(mkLocalsAuthed(state))); + const call = state.queries[0].calls.find(c => c.method === 'eq' && (c.args[0] === 'evaluation_window')); + expect(call).toBeDefined(); + expect(call?.args[1]).toBe('last_7_days'); + }); + + it('returns empty array when no rows yet (D-07: 24h window after Phase 14 ships)', async () => { + const state = freshState({ forecast_quality: [] }); + const res = await forecastQualityGET(mkEvent(mkLocalsAuthed(state))); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +// -------------------- /api/campaign-uplift -------------------- +import { GET as campaignUpliftGET } from '../../src/routes/api/campaign-uplift/+server'; + +describe('/api/campaign-uplift', () => { + // forecast_with_actual_v rows since CAMPAIGN_START (2026-04-14). + // Σ(actual − yhat) = (1500-1400) + (1700-1600) + (1300-1500) = 0 + const upliftRows = [ + { target_date: '2026-04-14', model_name: 'sarimax', kpi_name: 'revenue_eur', + forecast_track: 'bau', yhat: 1400, yhat_lower: 1300, yhat_upper: 1500, actual_value: 1500 }, + { target_date: '2026-04-15', model_name: 'sarimax', kpi_name: 'revenue_eur', + forecast_track: 'bau', yhat: 1600, yhat_lower: 1500, yhat_upper: 1700, actual_value: 1700 }, + { target_date: '2026-04-16', model_name: 'sarimax', kpi_name: 'revenue_eur', + forecast_track: 'bau', yhat: 1500, yhat_lower: 1400, yhat_upper: 1600, actual_value: 1300 } + ]; + + it('authenticated GET returns 200 with {campaign_start, cumulative_deviation_eur, as_of}', async () => { + const state = freshState({ forecast_with_actual_v: upliftRows }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.campaign_start).toBe('2026-04-14'); + expect(body.cumulative_deviation_eur).toBeCloseTo(0, 6); + expect(typeof body.as_of).toBe('string'); + }); + + it('cumulative_deviation_eur sums (actual − yhat) across all rows since CAMPAIGN_START', async () => { + const state = freshState({ + forecast_with_actual_v: [ + { ...upliftRows[0], actual_value: 1500, yhat: 1400 }, // +100 + { ...upliftRows[1], actual_value: 1500, yhat: 1700 } // -200 + ] + }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + const body = await res.json(); + expect(body.cumulative_deviation_eur).toBeCloseTo(-100, 6); + }); + + it('rows where actual_value is null (future dates) are excluded from the sum', async () => { + const state = freshState({ + forecast_with_actual_v: [ + { ...upliftRows[0], actual_value: 1500, yhat: 1400 }, // +100 + { ...upliftRows[1], actual_value: null, yhat: 1700 } // skipped + ] + }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + const body = await res.json(); + expect(body.cumulative_deviation_eur).toBeCloseTo(100, 6); + }); + + it('null claims returns 401 and never touches supabase', async () => { + const state = freshState(); + const res = await campaignUpliftGET(mkEvent(mkLocalsUnauthed(state))); + expect(res.status).toBe(401); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('200 response carries Cache-Control: private, no-store', async () => { + const state = freshState({ forecast_with_actual_v: [] }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('handler applies kpi_name=revenue_eur, forecast_track=bau, model_name=sarimax, gte target_date', async () => { + const state = freshState({ forecast_with_actual_v: upliftRows }); + await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + const eqCalls = state.queries[0].calls.filter(c => c.method === 'eq'); + const eqMap = Object.fromEntries(eqCalls.map(c => [c.args[0] as string, c.args[1]])); + expect(eqMap).toMatchObject({ + kpi_name: 'revenue_eur', + forecast_track: 'bau', + model_name: 'sarimax' + }); + const gteCall = state.queries[0].calls.find(c => c.method === 'gte'); + expect(gteCall?.args).toEqual(['target_date', '2026-04-14']); + }); + + it('zero rows since campaign-start returns 0 deviation, not null', async () => { + const state = freshState({ forecast_with_actual_v: [] }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + const body = await res.json(); + expect(body.cumulative_deviation_eur).toBe(0); + }); + + it('supabase error surfaces as 500', async () => { + const state = freshState(); + state.errors.set('forecast_with_actual_v', { message: 'boom' }); + const res = await campaignUpliftGET(mkEvent(mkLocalsAuthed(state))); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/unit/chartPalettes.test.ts b/tests/unit/chartPalettes.test.ts index 7af2f03..b61a5b9 100644 --- a/tests/unit/chartPalettes.test.ts +++ b/tests/unit/chartPalettes.test.ts @@ -5,8 +5,10 @@ import { VISIT_SEQ_COLORS, CASH_COLOR, ITEM_COLORS, - OTHER_COLOR + OTHER_COLOR, + FORECAST_MODEL_COLORS } from '../../src/lib/chartPalettes'; +import { schemeTableau10 } from 'd3-scale-chromatic'; describe('chartPalettes', () => { it('VISIT_SEQ_COLORS has 8 distinct sequential shades (D-06)', () => { @@ -27,3 +29,33 @@ describe('chartPalettes', () => { expect(OTHER_COLOR).toBe(CASH_COLOR); }); }); + +describe('FORECAST_MODEL_COLORS (Phase 15 D-10)', () => { + it('contains keys for the 5 BAU models + 2 feature-flagged models', () => { + expect(Object.keys(FORECAST_MODEL_COLORS).sort()).toEqual([ + 'chronos', + 'ets', + 'naive_dow', + 'neuralprophet', + 'prophet', + 'sarimax', + 'theta' + ]); + }); + + it('first 4 BAU models use schemeTableau10[0..3] in the documented order', () => { + expect(FORECAST_MODEL_COLORS.sarimax).toBe(schemeTableau10[0]); + expect(FORECAST_MODEL_COLORS.prophet).toBe(schemeTableau10[1]); + expect(FORECAST_MODEL_COLORS.ets).toBe(schemeTableau10[2]); + expect(FORECAST_MODEL_COLORS.theta).toBe(schemeTableau10[3]); + }); + + it('naive_dow is the de-emphasized gray baseline (#a1a1aa, matches CASH_COLOR)', () => { + expect(FORECAST_MODEL_COLORS.naive_dow).toBe('#a1a1aa'); + }); + + it('Chronos / NeuralProphet pick up schemeTableau10[5..6] when their flags flip on', () => { + expect(FORECAST_MODEL_COLORS.chronos).toBe(schemeTableau10[5]); + expect(FORECAST_MODEL_COLORS.neuralprophet).toBe(schemeTableau10[6]); + }); +}); diff --git a/tests/unit/forecastConfig.test.ts b/tests/unit/forecastConfig.test.ts new file mode 100644 index 0000000..d342537 --- /dev/null +++ b/tests/unit/forecastConfig.test.ts @@ -0,0 +1,15 @@ +// tests/unit/forecastConfig.test.ts +// Phase 15 D-08 — hard-coded campaign-start. Phase 16 replaces this constant +// with a campaign_calendar lookup. The test pins the date so any drift fails CI. +import { describe, it, expect } from 'vitest'; +import { CAMPAIGN_START } from '../../src/lib/forecastConfig'; + +describe('forecastConfig', () => { + it('CAMPAIGN_START is 2026-04-14 (Phase 15 D-08 stub for friend-owner spring campaign)', () => { + expect(CAMPAIGN_START.toISOString().slice(0, 10)).toBe('2026-04-14'); + }); + + it('CAMPAIGN_START is a Date instance (not a string), so date-fns helpers can consume it directly', () => { + expect(CAMPAIGN_START).toBeInstanceOf(Date); + }); +}); diff --git a/tests/unit/forecastEmptyStates.test.ts b/tests/unit/forecastEmptyStates.test.ts new file mode 100644 index 0000000..78106f7 --- /dev/null +++ b/tests/unit/forecastEmptyStates.test.ts @@ -0,0 +1,29 @@ +// tests/unit/forecastEmptyStates.test.ts +// Phase 15 FUI-08 — empty-state copy + i18n keys for the four forecast states. +import { describe, it, expect } from 'vitest'; +import { emptyStates } from '../../src/lib/emptyStates'; +import { messages } from '../../src/lib/i18n/messages'; + +const FORECAST_KEYS = [ + 'forecast-loading', + 'forecast-quality-empty', + 'forecast-stale', + 'forecast-uncalibrated-ci' +] as const; + +describe('Forecast empty-state keys (FUI-08)', () => { + it.each(FORECAST_KEYS)('emptyStates["%s"] has heading + body keys', (k) => { + const entry = emptyStates[k as keyof typeof emptyStates]; + expect(entry).toBeDefined(); + expect(entry.headingKey).toMatch(/^empty_forecast_/); + expect(entry.bodyKey).toMatch(/^empty_forecast_/); + }); + + it.each(FORECAST_KEYS)('en locale has matching heading + body for "%s"', (k) => { + const entry = emptyStates[k as keyof typeof emptyStates]; + expect(messages.en[entry.headingKey]).toBeTypeOf('string'); + expect(messages.en[entry.bodyKey]).toBeTypeOf('string'); + expect(messages.en[entry.headingKey].length).toBeGreaterThan(0); + expect(messages.en[entry.bodyKey].length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/forecastEventClamp.test.ts b/tests/unit/forecastEventClamp.test.ts new file mode 100644 index 0000000..74de91f --- /dev/null +++ b/tests/unit/forecastEventClamp.test.ts @@ -0,0 +1,88 @@ +// tests/unit/forecastEventClamp.test.ts +// Phase 15 D-09 / FUI-05 — progressive disclosure: ≤50 markers at default zoom. +// When events exceed the cap, drop lowest-priority types first: +// campaign_start > transit_strike > school_holiday > holiday > recurring_event +import { describe, it, expect } from 'vitest'; +import { clampEvents, EVENT_PRIORITY, type ForecastEvent } from '../../src/lib/forecastEventClamp'; + +const ev = (type: ForecastEvent['type'], date: string, label: string): ForecastEvent => + ({ type, date, label }); + +describe('clampEvents', () => { + it('returns input unchanged when count <= max', () => { + const events: ForecastEvent[] = [ + ev('holiday', '2026-05-01', 'Tag der Arbeit'), + ev('recurring_event', '2026-05-15', 'Berlin Marathon') + ]; + expect(clampEvents(events, 50)).toEqual(events); + }); + + it('drops lowest-priority type first when over cap', () => { + const events: ForecastEvent[] = [ + ...Array.from({ length: 30 }, (_, i) => ev('recurring_event', `2026-05-${String(i + 1).padStart(2, '0')}`, `r${i}`)), + ...Array.from({ length: 30 }, (_, i) => ev('holiday', `2026-06-${String(i + 1).padStart(2, '0')}`, `h${i}`)) + ]; + const out = clampEvents(events, 50); + expect(out.length).toBeLessThanOrEqual(50); + // Should keep all 30 holidays (higher priority) and drop 10 recurring. + expect(out.filter(e => e.type === 'holiday').length).toBe(30); + expect(out.filter(e => e.type === 'recurring_event').length).toBe(20); + }); + + it('campaign_start always survives — never dropped', () => { + const events: ForecastEvent[] = [ + ev('campaign_start', '2026-04-14', 'Spring campaign'), + ...Array.from({ length: 60 }, (_, i) => + ev('recurring_event', `2026-05-${String(i + 1).padStart(2, '0')}`, `r${i}`)) + ]; + const out = clampEvents(events, 50); + expect(out.find(e => e.type === 'campaign_start')).toBeDefined(); + }); + + it('priority order is campaign > transit > school > holiday > recurring (D-09)', () => { + expect(EVENT_PRIORITY.campaign_start).toBeGreaterThan(EVENT_PRIORITY.transit_strike); + expect(EVENT_PRIORITY.transit_strike).toBeGreaterThan(EVENT_PRIORITY.school_holiday); + expect(EVENT_PRIORITY.school_holiday).toBeGreaterThan(EVENT_PRIORITY.holiday); + expect(EVENT_PRIORITY.holiday).toBeGreaterThan(EVENT_PRIORITY.recurring_event); + }); + + it('within a single type, earlier dates win when tied at the cap boundary', () => { + const events: ForecastEvent[] = Array.from({ length: 60 }, (_, i) => + ev('holiday', `2026-${String(Math.floor(i / 30) + 5).padStart(2, '0')}-${String((i % 30) + 1).padStart(2, '0')}`, `h${i}`)); + const out = clampEvents(events, 50); + expect(out.length).toBe(50); + const dates = out.map(e => e.date).sort(); + // String compare: first kept date should sort before last kept date. + expect(dates[0] < dates[dates.length - 1]).toBe(true); + }); + + // Dedupe: federal+Berlin holiday rows that share (type, date, label). + // Without this, EventMarker's keyed-each `(e.type + '|' + e.date)` would + // crash Svelte 5 with `each_key_duplicate` at runtime. + it('dedupes identical (type, date, label) tuples — federal+Berlin holiday overlap', () => { + const events: ForecastEvent[] = [ + ev('holiday', '2026-05-01', 'Tag der Arbeit'), + ev('holiday', '2026-05-01', 'Tag der Arbeit') // duplicate (federal + BE row) + ]; + const out = clampEvents(events, 50); + expect(out.length).toBe(1); + expect(out[0]).toEqual(ev('holiday', '2026-05-01', 'Tag der Arbeit')); + }); + + it('preserves events with same (type, date) but different labels', () => { + const events: ForecastEvent[] = [ + ev('recurring_event', '2026-09-26', 'Berlin Marathon'), + ev('recurring_event', '2026-09-26', 'Festival of Lights') + ]; + const out = clampEvents(events, 50); + expect(out.length).toBe(2); + }); + + it('dedupe runs before cap — 60 events with 20 duplicates collapse to 40 (no clamp)', () => { + const unique: ForecastEvent[] = Array.from({ length: 40 }, (_, i) => + ev('holiday', `2026-05-${String(i + 1).padStart(2, '0')}`, `h${i}`)); + const dupes: ForecastEvent[] = unique.slice(0, 20).map(e => ({ ...e })); // 20 exact dupes + const out = clampEvents([...unique, ...dupes], 50); + expect(out.length).toBe(40); + }); +}); diff --git a/tests/unit/forecastValidation.test.ts b/tests/unit/forecastValidation.test.ts new file mode 100644 index 0000000..03846d2 --- /dev/null +++ b/tests/unit/forecastValidation.test.ts @@ -0,0 +1,45 @@ +// tests/unit/forecastValidation.test.ts +// Phase 15 v2 D-14 — parser tests only. +// +// Plan 15-11 dropped the horizon × granularity clamp matrix (forecast is +// fitted natively per grain by 15-10), so isValidCombo / +// DEFAULT_GRANULARITY / the Horizon type were removed. The remaining +// surface — parseHorizon + parseGranularity + HORIZON_DAYS / GRANULARITIES +// constants — is what the new endpoint and HorizonToggle consume. +import { describe, it, expect } from 'vitest'; +import { + parseHorizon, + parseGranularity, + HORIZON_DAYS, + GRANULARITIES +} from '../../src/lib/forecastValidation'; + +describe('parseHorizon', () => { + it.each(['7', '35', '120', '365'])('accepts numeric horizon "%s"', (s) => { + expect(parseHorizon(s)).not.toBeNull(); + }); + it('returns null for missing param', () => { expect(parseHorizon(null)).toBeNull(); }); + it('returns null for unsupported horizon', () => { expect(parseHorizon('30')).toBeNull(); }); + it('returns null for junk input', () => { expect(parseHorizon('abc')).toBeNull(); }); +}); + +describe('parseGranularity', () => { + it('accepts day | week | month', () => { + expect(parseGranularity('day')).toBe('day'); + expect(parseGranularity('week')).toBe('week'); + expect(parseGranularity('month')).toBe('month'); + }); + it('returns null for missing or junk', () => { + expect(parseGranularity(null)).toBeNull(); + expect(parseGranularity('hour')).toBeNull(); + }); +}); + +describe('constants', () => { + it('HORIZON_DAYS exposes 7/35/120/365 (FUI-03)', () => { + expect(HORIZON_DAYS).toEqual([7, 35, 120, 365]); + }); + it('GRANULARITIES exposes day/week/month', () => { + expect(GRANULARITIES).toEqual(['day', 'week', 'month']); + }); +});