From 07b6f1fceaafdd4e5018f9a15449cb4a4349f549 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 00:58:19 +0200 Subject: [PATCH 01/33] feat(15v2): squash-port Phase 15 v1 scaffolding to backtest-overlay branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #25 (Phase 15 v1 — forecast-only forward chart) closed in favor of v2. This commit brings forward all reusable v1 code as a single starting point. Reusable v1 surfaces ported (will evolve in plans 15-09 through 15-17): - src/lib/forecastConfig.ts (CAMPAIGN_START) - src/lib/chartPalettes.ts (FORECAST_MODEL_COLORS — sarimax key per Phase 14 contract) - src/lib/emptyStates.ts (4 forecast empty-state keys) - src/lib/i18n/messages.ts (8 locale-mirrored sections: horizon/legend/popup/card) - src/lib/forecastValidation.ts (parseHorizon/parseGranularity — horizon clamp dropped in 15-11) - src/lib/forecastResampling.ts (will be DELETED in 15-11 — v2 stores forecasts at native grain) - src/lib/forecastEventClamp.ts (incl. dedupe fix) - src/routes/api/forecast/+server.ts (will be REFACTORED in 15-11) - src/routes/api/forecast-quality/+server.ts - src/routes/api/campaign-uplift/+server.ts - src/lib/components/HorizonToggle.svelte (will be DELETED in 15-14) - src/lib/components/ForecastLegend.svelte (reused inline by overlays) - src/lib/components/EventMarker.svelte - src/lib/components/ForecastHoverPopup.svelte - src/lib/components/RevenueForecastCard.svelte (will be REWRITTEN in 15-14) - src/routes/+page.svelte (mount evolves: 15-15 adds InvoiceCount sibling, 15-17 retires both) - All matching unit tests (incl. forecastResampling test which drops with 15-11) v1 fixes preserved: - clampEvents dedupe by (type, date, label) — prevents Svelte 5 each_key_duplicate crash on multi-source German holiday days - sarimax_bau → sarimax model_name alignment with Phase 14 contract v1 PLAN.md docs are NOT ported — v2 plans (15-09..15-17) are written fresh to reflect the corrected mental model: forecasts as overlays on actuals charts, grain-specific TRAIN_ENDs, weekly refresh cadence. --- src/lib/chartPalettes.ts | 29 ++ src/lib/components/EventMarker.svelte | 114 ++++++ src/lib/components/ForecastHoverPopup.svelte | 149 ++++++++ src/lib/components/ForecastLegend.svelte | 88 +++++ src/lib/components/HorizonToggle.svelte | 70 ++++ src/lib/components/RevenueForecastCard.svelte | 324 ++++++++++++++++++ src/lib/emptyStates.ts | 8 +- src/lib/forecastConfig.ts | 11 + src/lib/forecastEventClamp.ts | 63 ++++ src/lib/forecastResampling.ts | 76 ++++ src/lib/forecastValidation.ts | 47 +++ src/lib/i18n/messages.ts | 225 ++++++++++++ src/routes/+page.svelte | 115 +++++++ src/routes/api/campaign-uplift/+server.ts | 66 ++++ src/routes/api/forecast-quality/+server.ts | 50 +++ src/routes/api/forecast/+server.ts | 180 ++++++++++ tests/unit/EventMarker.test.ts | 129 +++++++ tests/unit/ForecastHoverPopup.test.ts | 167 +++++++++ tests/unit/ForecastLegend.test.ts | 118 +++++++ tests/unit/HorizonToggle.test.ts | 107 ++++++ tests/unit/RevenueForecastCard.test.ts | 117 +++++++ tests/unit/apiEndpoints.test.ts | 287 ++++++++++++++++ tests/unit/chartPalettes.test.ts | 34 +- tests/unit/forecastConfig.test.ts | 15 + tests/unit/forecastEmptyStates.test.ts | 29 ++ tests/unit/forecastEventClamp.test.ts | 88 +++++ tests/unit/forecastResampling.test.ts | 71 ++++ tests/unit/forecastValidation.test.ts | 63 ++++ 28 files changed, 2838 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/EventMarker.svelte create mode 100644 src/lib/components/ForecastHoverPopup.svelte create mode 100644 src/lib/components/ForecastLegend.svelte create mode 100644 src/lib/components/HorizonToggle.svelte create mode 100644 src/lib/components/RevenueForecastCard.svelte create mode 100644 src/lib/forecastConfig.ts create mode 100644 src/lib/forecastEventClamp.ts create mode 100644 src/lib/forecastResampling.ts create mode 100644 src/lib/forecastValidation.ts create mode 100644 src/routes/api/campaign-uplift/+server.ts create mode 100644 src/routes/api/forecast-quality/+server.ts create mode 100644 src/routes/api/forecast/+server.ts create mode 100644 tests/unit/EventMarker.test.ts create mode 100644 tests/unit/ForecastHoverPopup.test.ts create mode 100644 tests/unit/ForecastLegend.test.ts create mode 100644 tests/unit/HorizonToggle.test.ts create mode 100644 tests/unit/RevenueForecastCard.test.ts create mode 100644 tests/unit/forecastConfig.test.ts create mode 100644 tests/unit/forecastEmptyStates.test.ts create mode 100644 tests/unit/forecastEventClamp.test.ts create mode 100644 tests/unit/forecastResampling.test.ts create mode 100644 tests/unit/forecastValidation.test.ts 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/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/HorizonToggle.svelte b/src/lib/components/HorizonToggle.svelte new file mode 100644 index 0000000..faaf5ce --- /dev/null +++ b/src/lib/components/HorizonToggle.svelte @@ -0,0 +1,70 @@ + + + +
+ {#each options as opt (opt.value)} + + {/each} +
diff --git a/src/lib/components/RevenueForecastCard.svelte b/src/lib/components/RevenueForecastCard.svelte new file mode 100644 index 0000000..016e95b --- /dev/null +++ b/src/lib/components/RevenueForecastCard.svelte @@ -0,0 +1,324 @@ + + +
+ +
+

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

+
+ {#if showStaleBadge} + + {t(page.data.locale, 'empty_forecast_stale_heading')} + + {/if} + {#if showUncalibratedBadge} + + {t(page.data.locale, 'forecast_uncalibrated_badge')} + + {/if} +
+
+

+ {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')} /> + + + {#if bandRows.length > 0} + ({ ...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[PRIMARY_MODEL]} + fillOpacity={0.15} + /> + {/if} + + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (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} + 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..ec78908 100644 --- a/src/lib/emptyStates.ts +++ b/src/lib/emptyStates.ts @@ -19,7 +19,13 @@ 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' } } 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/forecastResampling.ts b/src/lib/forecastResampling.ts new file mode 100644 index 0000000..1cdae5e --- /dev/null +++ b/src/lib/forecastResampling.ts @@ -0,0 +1,76 @@ +// src/lib/forecastResampling.ts +// Phase 14 C-05 / D-04: server-side resampling of daily forecast rows +// into week or month grains. Aggregation is mean(yhat_mean), mean(yhat_lower), +// mean(yhat_upper) per (model_name, bucket_start_date). horizon_days collapses +// to the smallest horizon in the bucket (the earliest-target-date row drives it). +// +// Why mean and not sum: yhat is already a per-day expected value. Summing +// would imply "weekly total" which is a different KPI; mean preserves the +// "expected daily value" semantic so the y-axis stays consistent across grains. +// +// ISO week start = Monday. date-fns startOfWeek({ weekStartsOn: 1 }). +import { startOfWeek, startOfMonth, format } from 'date-fns'; +import type { Granularity } from './forecastValidation'; + +export type ForecastRowDaily = { + target_date: string; // YYYY-MM-DD + model_name: string; + yhat_mean: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; +}; + +export type ForecastRowOut = ForecastRowDaily; + +export function resampleByGranularity( + rows: readonly ForecastRowDaily[], + granularity: Granularity +): ForecastRowOut[] { + if (granularity === 'day') return rows.slice(); + + const bucketKey = (dateStr: string): string => { + const d = new Date(dateStr + 'T00:00:00Z'); + const start = granularity === 'week' + ? startOfWeek(d, { weekStartsOn: 1 }) + : startOfMonth(d); + return format(start, 'yyyy-MM-dd'); + }; + + type Acc = { sumMean: number; sumLower: number; sumUpper: number; n: number; minHorizon: number }; + const buckets = new Map(); // key = `${model_name}|${bucket_date}` + + for (const r of rows) { + const key = `${r.model_name}|${bucketKey(r.target_date)}`; + const cur = buckets.get(key); + if (cur) { + cur.sumMean += r.yhat_mean; + cur.sumLower += r.yhat_lower; + cur.sumUpper += r.yhat_upper; + cur.n += 1; + if (r.horizon_days < cur.minHorizon) cur.minHorizon = r.horizon_days; + } else { + buckets.set(key, { + sumMean: r.yhat_mean, + sumLower: r.yhat_lower, + sumUpper: r.yhat_upper, + n: 1, + minHorizon: r.horizon_days + }); + } + } + + const out: ForecastRowOut[] = []; + for (const [key, acc] of buckets) { + const [model_name, target_date] = key.split('|'); + out.push({ + target_date, + model_name, + yhat_mean: acc.sumMean / acc.n, + yhat_lower: acc.sumLower / acc.n, + yhat_upper: acc.sumUpper / acc.n, + horizon_days: acc.minHorizon + }); + } + return out; +} diff --git a/src/lib/forecastValidation.ts b/src/lib/forecastValidation.ts new file mode 100644 index 0000000..97b1096 --- /dev/null +++ b/src/lib/forecastValidation.ts @@ -0,0 +1,47 @@ +// src/lib/forecastValidation.ts +// Phase 15 D-11 — horizon × granularity clamp matrix. +// Mirrors the Phase 10 D-17 cohort grain clamp pattern. The endpoint +// rejects illegal combos with HTTP 400 so an attacker can't ask for +// 365 daily bars (1px each at 375px and 365 subrequests cost on CF). + +export const HORIZON_DAYS = [7, 35, 120, 365] as const; +export type Horizon = typeof HORIZON_DAYS[number]; + +export const GRANULARITIES = ['day', 'week', 'month'] as const; +export type Granularity = typeof GRANULARITIES[number]; + +export function parseHorizon(raw: string | null): Horizon | null { + if (raw === null) return null; + const n = Number(raw); + return (HORIZON_DAYS as readonly number[]).includes(n) ? (n as Horizon) : 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; +} + +// D-11 clamp matrix: which (horizon, granularity) combos the endpoint accepts. +// 7d → day only (35 daily bars max — readable on 375px) +// 5w → day | week +// 4mo → week | month (no day — 120 daily bars overflow 375px) +// 1yr → month only (no day, no week — 365 day or 52 week bars unreadable) +const VALID: Record> = { + 7: new Set(['day']), + 35: new Set(['day', 'week']), + 120: new Set(['week', 'month']), + 365: new Set(['month']) +}; + +export function isValidCombo(horizon: Horizon, granularity: Granularity): boolean { + return VALID[horizon].has(granularity); +} + +// Default granularity for each horizon — used when the client omits ?granularity=. +// Picks the smallest valid bucket (day where possible, week for 4mo, month for 1yr). +export const DEFAULT_GRANULARITY: Record = { + 7: 'day', + 35: 'day', + 120: 'week', + 365: 'month' +}; diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index a8b8464..24eff9c 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -22,6 +22,41 @@ const en = { grain_month: 'Month', grain_selector_aria: 'Grain selector', + // --- Horizon toggle (Phase 15 FUI-03) ---------------------------------- + horizon_7d: '7d', + horizon_5w: '5w', + horizon_4mo: '4mo', + horizon_1yr: '1yr', + horizon_selector_aria: 'Forecast horizon 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', + // --- KPI tiles (+page.svelte builds "Revenue · {range}") --------------- kpi_revenue: 'Revenue', kpi_transactions: 'Transactions', @@ -121,6 +156,16 @@ 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.', + // --- InsightCard footer + edit form ------------------------------------ insight_week_ending: 'Week ending {date}', insight_refreshed_weekly: 'Refreshed weekly', @@ -167,6 +212,41 @@ const de: Record = { grain_month: 'Monat', grain_selector_aria: 'Zeitraster-Auswahl', + // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN + horizon_7d: '7d', + horizon_5w: '5w', + horizon_4mo: '4mo', + horizon_1yr: '1yr', + horizon_selector_aria: 'Forecast horizon selector', + + // 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', + kpi_revenue: 'Umsatz', kpi_transactions: 'Transaktionen', range_today: 'Heute', @@ -260,6 +340,16 @@ 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.', + 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 +393,41 @@ const ja: Record = { grain_month: '月', grain_selector_aria: '期間粒度の選択', + // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN + horizon_7d: '7d', + horizon_5w: '5w', + horizon_4mo: '4mo', + horizon_1yr: '1yr', + horizon_selector_aria: 'Forecast horizon selector', + + // 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', + kpi_revenue: '売上', kpi_transactions: '取引件数', range_today: '本日', @@ -395,6 +520,16 @@ 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.', + insight_week_ending: '{date}終了週', insight_refreshed_weekly: '週次更新', insight_refreshed_with_last_run: '週次更新 · 最終実行 {date}', @@ -438,6 +573,41 @@ const es: Record = { grain_month: 'Mes', grain_selector_aria: 'Selector de granularidad', + // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN + horizon_7d: '7d', + horizon_5w: '5w', + horizon_4mo: '4mo', + horizon_1yr: '1yr', + horizon_selector_aria: 'Forecast horizon selector', + + // 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', + kpi_revenue: 'Ingresos', kpi_transactions: 'Transacciones', range_today: 'Hoy', @@ -531,6 +701,16 @@ 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.', + 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 +754,41 @@ const fr: Record = { grain_month: 'Mois', grain_selector_aria: 'Sélecteur de granularité', + // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN + horizon_7d: '7d', + horizon_5w: '5w', + horizon_4mo: '4mo', + horizon_1yr: '1yr', + horizon_selector_aria: 'Forecast horizon selector', + + // 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', + kpi_revenue: "Chiffre d'affaires", kpi_transactions: 'Transactions', range_today: "Aujourd'hui", @@ -667,6 +882,16 @@ 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.', + 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..ea833cc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,6 +18,7 @@ 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 LazyMount from '$lib/components/LazyMount.svelte'; import { clientFetch } from '$lib/clientFetch'; import { @@ -92,6 +93,103 @@ } catch (e) { console.error('[LazyMount /api/retention]', e); } } + // Phase 15 D-01 / FUI-07: deferred client-fetches for the forecast card. + // Three endpoints (forecast / forecast-quality / campaign-uplift) all use + // locals.safeGetSession() + Cache-Control: private, no-store per Phase 11 + // D-03. /api/forecast re-fires when horizon or granularity change so the + // server can re-resample the sample paths at the new grain. + type ForecastRow = { + target_date: string; + model_name: string; + yhat_mean: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; + }; + type ForecastEvent = { + type: 'campaign_start' | 'transit_strike' | 'school_holiday' | 'holiday' | 'recurring_event'; + date: string; + label: string; + end_date?: string; + }; + type ForecastPayload = { + rows: ForecastRow[]; + actuals: { date: string; value: number }[]; + events: ForecastEvent[]; + last_run: string | null; + }; + type QualityRow = { + model_name: string; + kpi_name: string; + horizon_days: number; + rmse: number; + mape: number; + mean_bias: number; + direction_hit_rate: number | null; + evaluated_at: string; + }; + type UpliftPayload = { + campaign_start: string; + cumulative_deviation_eur: number; + as_of: string; + }; + + let forecastData = $state(null); + let qualityData = $state([]); + let campaignUpliftData = $state(null); + let forecastHorizon = $state<7 | 35 | 120 | 365>(7); + let forecastGranularity = $state<'day' | 'week' | 'month'>('day'); + + // Plain JS flag — NOT $state — so the $effect below doesn't track it as + // a reactive dep. With $state, writing forecastData inside the effect + // would re-fire the effect on its own write and burn an extra cache-hit + // fetch on first load (the second fetch is wasted; it returns the same + // payload from the in-memory cache and Svelte 5's reference-equality + // short-circuits the third). Keeping initialFetchDone non-reactive + // means the effect only runs when the user actually changes + // forecastHorizon or forecastGranularity. + let initialFetchDone = false; + + async function loadForecastBundle() { + const horizon = forecastHorizon; + const granularity = forecastGranularity; + try { + const [f, q, u] = await Promise.all([ + clientFetch(`/api/forecast?horizon=${horizon}&granularity=${granularity}`), + clientFetch('/api/forecast-quality'), + clientFetch('/api/campaign-uplift') + ]); + forecastData = f; + qualityData = q; + campaignUpliftData = u; + initialFetchDone = true; + } catch (e) { + console.error('[LazyMount /api/forecast bundle]', e); + } + } + + // Re-fetch /api/forecast on horizon/granularity change. Tracks ONLY + // forecastHorizon + forecastGranularity. initialFetchDone is a plain + // let (not $state) so the effect does not depend on it; the LazyMount + // populates forecastData on first visibility, then this effect handles + // user-driven horizon/granularity changes. + $effect(() => { + const h = forecastHorizon; + const g = forecastGranularity; + if (!initialFetchDone) return; + void clientFetch(`/api/forecast?horizon=${h}&granularity=${g}`) + .then(f => { forecastData = f; }) + .catch(e => console.error('[forecast horizon change]', e)); + }); + + // Compute hours since last freshness ping for the stale-data badge. + // data.freshness is the existing FreshnessLabel input; reusing it keeps + // the badge consistent with the page-level "Last updated …" line. + function staleHours(iso: string | null): number { + if (!iso) return 0; + return Math.max(0, (Date.now() - new Date(iso).getTime()) / 3_600_000); + } + // Initialize store from SSR data on mount and when SSR data changes. $effect(() => { initStore({ @@ -253,6 +351,23 @@ {/if} + + + {#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..0a1aa2a --- /dev/null +++ b/src/routes/api/forecast/+server.ts @@ -0,0 +1,180 @@ +// src/routes/api/forecast/+server.ts +// Phase 15 D-06 / D-09 / D-11 / FUI-07. +// Deferred endpoint for RevenueForecastCard. Long-format rows + sibling +// events array + last_run timestamp. Server-side resampling per granularity +// per Phase 14 C-05 / D-04 (client never receives raw 200-path arrays). +// +// Auth: locals.safeGetSession() (canonical helper). RLS is enforced by +// forecast_with_actual_v's WHERE clause (auth.jwt()->>'restaurant_id'). +// pipeline_runs_status_v applies its own caller-JWT row filter (Phase 13 +// migration 0049). Holidays / school_holidays / recurring_events / +// transit_alerts are global tables (no tenant scoping needed; they're +// public knowledge). +// Cache-Control: private, no-store — prevents CDN cross-tenant leakage. +// CF Pages 50-subrequest budget (Phase 11 D-06): this handler issues 6 +// parallel Supabase queries (~6 subrequests) — well under the cap. +// +// Validation: ?horizon= must be in {7,35,120,365}; ?granularity= must +// pair legally per D-11. Illegal combos → 400 (no DB call). +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { fetchAll } from '$lib/supabasePagination'; +import { + parseHorizon, + parseGranularity, + isValidCombo, + DEFAULT_GRANULARITY, + type Granularity +} from '$lib/forecastValidation'; +import { resampleByGranularity, type ForecastRowDaily } from '$lib/forecastResampling'; +import { clampEvents, type ForecastEvent } from '$lib/forecastEventClamp'; +import { addDays, format } from 'date-fns'; + +type ForecastViewRow = { + target_date: string; + model_name: string; + yhat: number; + yhat_lower: number; + yhat_upper: number; + horizon_days: number; + actual_value: number | null; + forecast_track: string; + kpi_name: string; +}; + +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' }; + +export const GET: RequestHandler = async ({ locals, url }) => { + const { claims } = await locals.safeGetSession(); + if (!claims) return json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE }); + + const horizon = parseHorizon(url.searchParams.get('horizon')); + if (horizon === null) { + return json({ error: 'invalid horizon (must be 7, 35, 120, or 365)' }, { status: 400, headers: NO_STORE }); + } + const rawGran = url.searchParams.get('granularity'); + const granularity: Granularity = + rawGran === null + ? DEFAULT_GRANULARITY[horizon] + : (parseGranularity(rawGran) ?? ('__INVALID__' as Granularity)); + if (!isValidCombo(horizon, granularity)) { + return json( + { error: `invalid (horizon=${horizon}, granularity=${rawGran}) combo per D-11 clamp` }, + { status: 400, headers: NO_STORE } + ); + } + + // Window: today → today + horizon days. Use UTC for boundary stability. + const today = format(new Date(), 'yyyy-MM-dd'); + const horizonEnd = format(addDays(new Date(), horizon), 'yyyy-MM-dd'); + + try { + const [forecastRows, holidayRows, schoolRows, recurRows, transitRows, pipelineRows] = await Promise.all([ + fetchAll(() => + locals.supabase + .from('forecast_with_actual_v') + .select('target_date,model_name,yhat,yhat_lower,yhat_upper,horizon_days,actual_value,forecast_track,kpi_name') + .eq('kpi_name', 'revenue_eur') + .eq('forecast_track', 'bau') + .gte('target_date', today) + .lte('target_date', horizonEnd) + .order('target_date', { ascending: true }) + ), + fetchAll(() => + locals.supabase + .from('holidays') + .select('date,name,country_code,subdiv_code') + .gte('date', today) + .lte('date', horizonEnd) + .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', today) + .lte('start_date', horizonEnd) + ), + fetchAll(() => + locals.supabase + .from('recurring_events') + .select('event_id,name,start_date,end_date,impact_estimate') + .gte('start_date', today) + .lte('start_date', horizonEnd) + ), + fetchAll(() => + locals.supabase + .from('transit_alerts') + .select('alert_id,title,pub_date,matched_keyword') + .gte('pub_date', today) + .lte('pub_date', horizonEnd) + ), + fetchAll(() => + locals.supabase + .from('pipeline_runs_status_v') + .select('step_name,status,finished_at') + .eq('status', 'success') + .order('finished_at', { ascending: false }) + ) + ]); + + // Map view rows -> daily-rate output shape (yhat -> yhat_mean). + const dailyRows: ForecastRowDaily[] = 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 + })); + const rows = resampleByGranularity(dailyRows, granularity); + + // Merge actual_value back as a separate map keyed by target_date so the + // client can render historical vs forecast on the same axis. The view's + // actual_value is non-null only for past dates; future dates remain null. + const actualByDate = new Map(); + for (const r of forecastRows) { + if (r.actual_value !== null && !actualByDate.has(r.target_date)) { + actualByDate.set(r.target_date, r.actual_value); + } + } + + // Build events sibling array. + 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. 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, + actuals: Array.from(actualByDate, ([date, value]) => ({ date, value })), + events: clampEvents(events, 50), + last_run + }, + { headers: NO_STORE } + ); + } catch (err) { + console.error('[/api/forecast]', err); + return json({ error: 'query failed' }, { status: 500, headers: NO_STORE }); + } +}; 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/HorizonToggle.test.ts b/tests/unit/HorizonToggle.test.ts new file mode 100644 index 0000000..c367738 --- /dev/null +++ b/tests/unit/HorizonToggle.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +// tests/unit/HorizonToggle.test.ts +// Phase 15 FUI-03 — 4-chip selector. Default 7d. Click emits both +// onhorizonchange(horizon) and ongranularitychange(default-grain-for-horizon). +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, fireEvent, cleanup } from '@testing-library/svelte'; +import HorizonToggle from '../../src/lib/components/HorizonToggle.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() + })) + }); + } +}); + +describe('HorizonToggle', () => { + it('renders 4 chips: 7d / 5w / 4mo / 1yr', () => { + const { getByRole } = render(HorizonToggle, { + horizon: 7, + onhorizonchange: () => {}, + ongranularitychange: () => {} + }); + expect(getByRole('radio', { name: /7d/ })).toBeInTheDocument(); + expect(getByRole('radio', { name: /5w/ })).toBeInTheDocument(); + expect(getByRole('radio', { name: /4mo/ })).toBeInTheDocument(); + expect(getByRole('radio', { name: /1yr/ })).toBeInTheDocument(); + }); + + it('marks the active chip with aria-checked=true matching prop', () => { + const { getByRole } = render(HorizonToggle, { + horizon: 35, + onhorizonchange: () => {}, + ongranularitychange: () => {} + }); + const active = getByRole('radio', { name: /5w/ }); + expect(active).toHaveAttribute('aria-checked', 'true'); + const inactive = getByRole('radio', { name: /7d/ }); + expect(inactive).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking 1yr fires onhorizonchange(365) AND ongranularitychange("month") via D-11 default', async () => { + const horizonSpy = vi.fn(); + const granSpy = vi.fn(); + const { getByRole } = render(HorizonToggle, { + horizon: 7, + onhorizonchange: horizonSpy, + ongranularitychange: granSpy + }); + await fireEvent.click(getByRole('radio', { name: /1yr/ })); + expect(horizonSpy).toHaveBeenCalledWith(365); + expect(granSpy).toHaveBeenCalledWith('month'); + }); + + it('clicking 5w fires onhorizonchange(35) + ongranularitychange("day") (smallest valid grain)', async () => { + const horizonSpy = vi.fn(); + const granSpy = vi.fn(); + const { getByRole } = render(HorizonToggle, { + horizon: 7, + onhorizonchange: horizonSpy, + ongranularitychange: granSpy + }); + await fireEvent.click(getByRole('radio', { name: /5w/ })); + expect(horizonSpy).toHaveBeenCalledWith(35); + expect(granSpy).toHaveBeenCalledWith('day'); + }); + + it('clicking 4mo fires onhorizonchange(120) + ongranularitychange("week")', async () => { + const horizonSpy = vi.fn(); + const granSpy = vi.fn(); + const { getByRole } = render(HorizonToggle, { + horizon: 7, + onhorizonchange: horizonSpy, + ongranularitychange: granSpy + }); + await fireEvent.click(getByRole('radio', { name: /4mo/ })); + expect(horizonSpy).toHaveBeenCalledWith(120); + expect(granSpy).toHaveBeenCalledWith('week'); + }); + + it('chip buttons each have min-h-11 class for touch-target spec', () => { + const { getAllByRole } = render(HorizonToggle, { + horizon: 7, + onhorizonchange: () => {}, + ongranularitychange: () => {} + }); + const chips = getAllByRole('radio'); + for (const c of chips) { + expect(c.className).toMatch(/min-h-11/); + } + }); +}); diff --git a/tests/unit/RevenueForecastCard.test.ts b/tests/unit/RevenueForecastCard.test.ts new file mode 100644 index 0000000..7e36df4 --- /dev/null +++ b/tests/unit/RevenueForecastCard.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +// tests/unit/RevenueForecastCard.test.ts +// Phase 15-08 — composition test. Verifies default-state markup + empty-state + +// stale/uncalibrated badge logic. 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'; +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. Same scaffold used in HorizonToggle / ForecastLegend / EventMarker / +// ForecastHoverPopup test files. +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: [], + events: [], + last_run: '2026-04-30T01:34:22Z' +}; + +describe('RevenueForecastCard', () => { + it('renders the card shell with title + description', () => { + const { container } = render(RevenueForecastCard, { + forecastData: FORECAST_PAYLOAD, + qualityData: [], + campaignUpliftData: { campaign_start: '2026-04-14', cumulative_deviation_eur: 0, as_of: '2026-05-01' }, + stalenessHours: 4 + }); + expect(container.querySelector('[data-testid="revenue-forecast-card"]')).toBeInTheDocument(); + expect(container.textContent).toMatch(/Revenue forecast/); + }); + + it('renders empty state when forecastData.rows is empty', () => { + const { container } = render(RevenueForecastCard, { + forecastData: { rows: [], actuals: [], events: [], last_run: null }, + qualityData: [], + campaignUpliftData: null, + stalenessHours: 0 + }); + expect(container.textContent).toMatch(/Forecast generating|Check back tomorrow/); + }); + + it('mounts HorizonToggle and ForecastLegend when data present', () => { + const { container } = render(RevenueForecastCard, { + forecastData: FORECAST_PAYLOAD, + qualityData: [], + campaignUpliftData: null, + stalenessHours: 0 + }); + expect(container.querySelector('[data-testid="forecast-legend"]')).toBeInTheDocument(); + // HorizonToggle exposes role="group" with aria-label containing "horizon". + const groups = container.querySelectorAll('[role="group"]'); + const horizonGroup = Array.from(groups).find(g => + (g.getAttribute('aria-label') ?? '').toLowerCase().includes('horizon') + ); + expect(horizonGroup).toBeDefined(); + }); + + it('renders the stale-data badge when stalenessHours > 24', () => { + const { container } = render(RevenueForecastCard, { + forecastData: FORECAST_PAYLOAD, + qualityData: [], + campaignUpliftData: null, + stalenessHours: 36 + }); + expect(container.querySelector('[data-testid="forecast-stale-badge"]')).toBeInTheDocument(); + }); + + it('hides the stale-data badge when stalenessHours <= 24', () => { + const { container } = render(RevenueForecastCard, { + forecastData: FORECAST_PAYLOAD, + qualityData: [], + campaignUpliftData: null, + stalenessHours: 4 + }); + expect(container.querySelector('[data-testid="forecast-stale-badge"]')).not.toBeInTheDocument(); + }); + + it('does not render the uncalibrated-CI badge in default state (horizon=7d)', () => { + const { container } = render(RevenueForecastCard, { + forecastData: FORECAST_PAYLOAD, + qualityData: [], + campaignUpliftData: null, + stalenessHours: 0 + }); + expect(container.querySelector('[data-testid="forecast-uncalibrated-badge"]')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/apiEndpoints.test.ts b/tests/unit/apiEndpoints.test.ts index 6081ba9..1ac3203 100644 --- a/tests/unit/apiEndpoints.test.ts +++ b/tests/unit/apiEndpoints.test.ts @@ -405,3 +405,290 @@ describe('/api/repeater-lifetime', () => { expect(state.fromSpy).not.toHaveBeenCalled(); }); }); + +// -------------------- /api/forecast -------------------- +import { GET as forecastGET } from '../../src/routes/api/forecast/+server'; + +describe('/api/forecast', () => { + const fcastRow = { + target_date: '2026-05-01', + model_name: 'sarimax', + yhat: 1234.56, + yhat_lower: 1100, + yhat_upper: 1380, + horizon_days: 1, + actual_value: null, + forecast_track: 'bau', + kpi_name: 'revenue_eur' + }; + 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 ?horizon=7&granularity=day returns 200 with rows + events + last_run', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRow], + 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/?horizon=7&granularity=day')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.rows)).toBe(true); + expect(Array.isArray(body.events)).toBe(true); + expect(typeof body.last_run).toBe('string'); + 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 + }); + }); + + it('null claims returns 401 and never touches supabase', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsUnauthed(state), 'http://x/?horizon=7&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: [], holidays: [], school_holidays: [], + recurring_events: [], transit_alerts: [], pipeline_runs_status_v: [] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?horizon=7&granularity=day')); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); + + it('illegal combo (horizon=365 granularity=day) returns 400 and never touches supabase', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?horizon=365&granularity=day')); + expect(res.status).toBe(400); + expect(state.fromSpy).not.toHaveBeenCalled(); + }); + + it('missing horizon returns 400', async () => { + const state = freshState(); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/')); + expect(res.status).toBe(400); + }); + + it('omitted granularity falls back to DEFAULT_GRANULARITY for the horizon', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRow], holidays: [], school_holidays: [], + recurring_events: [], transit_alerts: [], pipeline_runs_status_v: [pipeRow] + }); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?horizon=7')); + expect(res.status).toBe(200); + }); + + it('events array carries holidays, school_holidays (start row), recurring, transit_strikes', async () => { + const state = freshState({ + forecast_with_actual_v: [fcastRow], + 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/?horizon=120&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: [], 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/?horizon=7&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/?horizon=7&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/forecastResampling.test.ts b/tests/unit/forecastResampling.test.ts new file mode 100644 index 0000000..fb5362b --- /dev/null +++ b/tests/unit/forecastResampling.test.ts @@ -0,0 +1,71 @@ +// tests/unit/forecastResampling.test.ts +// Phase 14 C-05 / D-04: server resamples daily forecast rows into week / month. +// Client never sees raw 200-path arrays — only mean + lower + upper per bucket. +// +// Resampling rule for week: bucket key = ISO Monday-start date of target_date. +// Resampling rule for month: bucket key = first-of-month date of target_date. +// Aggregation: mean of yhat_mean, mean of yhat_lower, mean of yhat_upper. +import { describe, it, expect } from 'vitest'; +import { resampleByGranularity, type ForecastRowDaily, type ForecastRowOut } from '../../src/lib/forecastResampling'; + +const sarimaxRow = (date: string, mean: number, lower: number, upper: number): ForecastRowDaily => ({ + target_date: date, model_name: 'sarimax', + yhat_mean: mean, yhat_lower: lower, yhat_upper: upper, horizon_days: 1 +}); + +describe('resampleByGranularity', () => { + it('day passthrough — returns input rows unchanged', () => { + const rows = [sarimaxRow('2026-05-04', 100, 90, 110), sarimaxRow('2026-05-05', 200, 180, 220)]; + expect(resampleByGranularity(rows, 'day')).toEqual(rows); + }); + + it('week bucket — Mon 2026-05-04 + Tue 2026-05-05 collapse to one row keyed 2026-05-04', () => { + const rows = [sarimaxRow('2026-05-04', 100, 90, 110), sarimaxRow('2026-05-05', 200, 180, 220)]; + const out = resampleByGranularity(rows, 'week'); + expect(out.length).toBe(1); + expect(out[0].target_date).toBe('2026-05-04'); + expect(out[0].yhat_mean).toBeCloseTo(150, 6); + expect(out[0].yhat_lower).toBeCloseTo(135, 6); + expect(out[0].yhat_upper).toBeCloseTo(165, 6); + }); + + it('month bucket — 2026-05-15 + 2026-05-31 + 2026-06-01 yield two rows keyed 2026-05-01 and 2026-06-01', () => { + const rows = [ + sarimaxRow('2026-05-15', 100, 90, 110), + sarimaxRow('2026-05-31', 200, 180, 220), + sarimaxRow('2026-06-01', 300, 270, 330) + ]; + const out = resampleByGranularity(rows, 'month'); + expect(out.map(r => r.target_date).sort()).toEqual(['2026-05-01', '2026-06-01']); + const may = out.find(r => r.target_date === '2026-05-01')!; + const jun = out.find(r => r.target_date === '2026-06-01')!; + expect(may.yhat_mean).toBeCloseTo(150, 6); + expect(jun.yhat_mean).toBeCloseTo(300, 6); + }); + + it('preserves model_name during resampling — buckets per (model, period)', () => { + const rows: ForecastRowDaily[] = [ + sarimaxRow('2026-05-04', 100, 90, 110), + { target_date: '2026-05-04', model_name: 'prophet', yhat_mean: 80, yhat_lower: 70, yhat_upper: 90, horizon_days: 1 } + ]; + const out = resampleByGranularity(rows, 'week'); + expect(out.length).toBe(2); + const models = out.map(r => r.model_name).sort(); + expect(models).toEqual(['prophet', 'sarimax']); + }); + + it('week bucket on a Sunday rolls back to the prior Monday (ISO week start)', () => { + const rows = [sarimaxRow('2026-05-10', 100, 90, 110)]; // 2026-05-10 is a Sunday + const out = resampleByGranularity(rows, 'week'); + expect(out[0].target_date).toBe('2026-05-04'); // ISO Monday of 2026-W19 + }); + + it('horizon_days on resampled rows is the smallest horizon in the bucket', () => { + const rows = [ + sarimaxRow('2026-05-04', 100, 90, 110), // horizon_days: 1 + { ...sarimaxRow('2026-05-05', 200, 180, 220), horizon_days: 2 } + ]; + const out = resampleByGranularity(rows, 'week'); + expect(out[0].horizon_days).toBe(1); + }); +}); diff --git a/tests/unit/forecastValidation.test.ts b/tests/unit/forecastValidation.test.ts new file mode 100644 index 0000000..024c5cc --- /dev/null +++ b/tests/unit/forecastValidation.test.ts @@ -0,0 +1,63 @@ +// tests/unit/forecastValidation.test.ts +// Phase 15 D-11 — validate ?horizon= + ?granularity= against the clamp matrix: +// 7d → day +// 5w → day | week +// 4mo → week | month +// 1yr → month +import { describe, it, expect } from 'vitest'; +import { + parseHorizon, + parseGranularity, + isValidCombo, + type Horizon, + type Granularity, + HORIZON_DAYS +} 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('isValidCombo (D-11 clamp matrix)', () => { + const valid: Array<[Horizon, Granularity]> = [ + [7, 'day'], + [35, 'day'], [35, 'week'], + [120, 'week'], [120, 'month'], + [365, 'month'] + ]; + const invalid: Array<[Horizon, Granularity]> = [ + [7, 'week'], [7, 'month'], + [35, 'month'], + [120, 'day'], + [365, 'day'], [365, 'week'] + ]; + it.each(valid)('accepts horizon=%i granularity=%s', (h, g) => { + expect(isValidCombo(h, g)).toBe(true); + }); + it.each(invalid)('rejects horizon=%i granularity=%s', (h, g) => { + expect(isValidCombo(h, g)).toBe(false); + }); +}); + +describe('HORIZON_DAYS constants', () => { + it('exposes 7/35/120/365 (FUI-03)', () => { + expect(HORIZON_DAYS).toEqual([7, 35, 120, 365]); + }); +}); From 53d325f3216a8f225e501e8cef95347e679b8512 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:20:22 +0200 Subject: [PATCH 02/33] =?UTF-8?q?docs(15v2):=20Context=20+=209=20plan=20do?= =?UTF-8?q?cs=20for=20Phase=2015=20v2=20=E2=80=94=20Forecast=20Backtest=20?= =?UTF-8?q?Overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plans 15-09 through 15-17 follow the writing-plans skill format. CONTEXT captures D-14..D-19 new decisions (grain-specific TRAIN_ENDs, calendar overlay rendering, weekly cron, option-B CI, dual-KPI parity, partial- month behavior) plus carry-forwards C-01..C-07 + D-01/02/04/05/06/07/ 08/09/10/12/13 from v1. v1's D-03 (today Rule) and D-11 (horizon clamp) explicitly retired. Plans: 15-09: forecast_daily granularity column + weekly cron 15-10: scripts/forecast/run_all.py 3-grain TRAIN_END loop 15-11: /api/forecast native-grain query + ?kpi= + backtest actuals 15-12: CalendarRevenueCard forecast overlay 15-13: CalendarCountsCard forecast overlay (invoice_count) 15-14: RevenueForecastCard rewrite (drop HorizonToggle, full range) 15-15: InvoiceCountForecastCard sibling 15-16: localhost gate + DEV deploy QA + STATE/ROADMAP closure 15-17 (deferred): retire dedicated cards once overlays validated Each plan keeps TDD discipline (failing test first, minimal impl, GREEN, commit). Each lists exact file paths, code blocks for critical logic, and verification commands. Ready for subagent-driven execution in a fresh session. --- .../15-09-PLAN.md | 164 ++++++++++ .../15-10-PLAN.md | 208 +++++++++++++ .../15-11-PLAN.md | 218 +++++++++++++ .../15-12-PLAN.md | 167 ++++++++++ .../15-13-PLAN.md | 56 ++++ .../15-14-PLAN.md | 286 ++++++++++++++++++ .../15-15-PLAN.md | 107 +++++++ .../15-16-PLAN.md | 52 ++++ .../15-17-PLAN.md | 39 +++ .../15-CONTEXT.md | 164 ++++++++++ 10 files changed, 1461 insertions(+) create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-09-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-10-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-11-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-12-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-13-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-14-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-15-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-16-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-17-PLAN.md create mode 100644 .planning/phases/15-forecast-backtest-overlay/15-CONTEXT.md 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* From c32f4dedfb9ae6aa4083a587de74355d1f8ae89e Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:29:20 +0200 Subject: [PATCH 03/33] feat(15-09): add granularity column to forecast_daily (D-14) --- .../0057_forecast_daily_granularity.sql | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 supabase/migrations/0057_forecast_daily_granularity.sql diff --git a/supabase/migrations/0057_forecast_daily_granularity.sql b/supabase/migrations/0057_forecast_daily_granularity.sql new file mode 100644 index 0000000..bc74415 --- /dev/null +++ b/supabase/migrations/0057_forecast_daily_granularity.sql @@ -0,0 +1,63 @@ +-- 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; From 30bf298e26a73fec104097e00edb47aadb593be3 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:29:33 +0200 Subject: [PATCH 04/33] feat(15-09): switch forecast-refresh to weekly Monday cron (D-16) --- .github/workflows/forecast-refresh.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From afecc911087a5ca906cc088bafe80aa473f7886e Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:31:28 +0200 Subject: [PATCH 05/33] test(15-09): integration tests for forecast_daily granularity column --- .../forecast_daily_granularity.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/integration/forecast_daily_granularity.test.ts diff --git a/tests/integration/forecast_daily_granularity.test.ts b/tests/integration/forecast_daily_granularity.test.ts new file mode 100644 index 0000000..8d34c9e --- /dev/null +++ b/tests/integration/forecast_daily_granularity.test.ts @@ -0,0 +1,148 @@ +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'); + }); +}); From e5f7325141d22009a380c8dfc84c45482d822cf9 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:36:29 +0200 Subject: [PATCH 06/33] test(15-09): add backfill assertion for forecast_daily granularity --- .../forecast_daily_granularity.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/integration/forecast_daily_granularity.test.ts b/tests/integration/forecast_daily_granularity.test.ts index 8d34c9e..1f0a7f4 100644 --- a/tests/integration/forecast_daily_granularity.test.ts +++ b/tests/integration/forecast_daily_granularity.test.ts @@ -145,4 +145,22 @@ describe('Phase 15-09: forecast_daily.granularity column', () => { 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'); + } + }); }); From d2b2ca92c43dfc2185ac68d4f7850a5b53270b43 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:42:41 +0200 Subject: [PATCH 07/33] docs(15-09): note RLS load-bearing JOIN predicate in forecast_with_actual_v MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review M-1: restaurant_id in the LEFT JOIN to kpi_daily_mv is what makes the wrapper view safe across tenants — kpi_daily_mv has no RLS of its own. Add a one-line comment so a future edit doesn't accidentally drop it. --- supabase/migrations/0057_forecast_daily_granularity.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supabase/migrations/0057_forecast_daily_granularity.sql b/supabase/migrations/0057_forecast_daily_granularity.sql index bc74415..3c16932 100644 --- a/supabase/migrations/0057_forecast_daily_granularity.sql +++ b/supabase/migrations/0057_forecast_daily_granularity.sql @@ -56,6 +56,8 @@ SELECT 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; From ab49a1205c669aee39200f8dae5599dfa894b963 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:45:04 +0200 Subject: [PATCH 08/33] feat(15-10): bucket-to-weekly + bucket-to-monthly helpers (D-14) --- scripts/forecast/aggregation.py | 21 ++++++++++++ scripts/forecast/tests/test_aggregation.py | 39 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 scripts/forecast/aggregation.py create mode 100644 scripts/forecast/tests/test_aggregation.py diff --git a/scripts/forecast/aggregation.py b/scripts/forecast/aggregation.py new file mode 100644 index 0000000..ad15120 --- /dev/null +++ b/scripts/forecast/aggregation.py @@ -0,0 +1,21 @@ +# 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 + +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 diff --git a/scripts/forecast/tests/test_aggregation.py b/scripts/forecast/tests/test_aggregation.py new file mode 100644 index 0000000..0c1ea3f --- /dev/null +++ b/scripts/forecast/tests/test_aggregation.py @@ -0,0 +1,39 @@ +# 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 From 2aa78419addf07831f045db3a1de65e309ae3820 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:48:22 +0200 Subject: [PATCH 09/33] feat(15-10): add date_col kwarg to bucket helpers (default 'business_date') Model fit scripts rename business_date -> date in _fetch_history before calling the aggregation helpers; let them pass date_col='date' instead of having to rename back. Default keeps existing tests green. --- scripts/forecast/aggregation.py | 17 ++++++++++----- scripts/forecast/tests/test_aggregation.py | 24 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/forecast/aggregation.py b/scripts/forecast/aggregation.py index ad15120..9e182d4 100644 --- a/scripts/forecast/aggregation.py +++ b/scripts/forecast/aggregation.py @@ -3,19 +3,26 @@ # 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) -> pd.DataFrame: - """Aggregate `df` (must have business_date column) into ISO-week buckets keyed by Monday start.""" +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['business_date'] - pd.to_timedelta(out['business_date'].dt.weekday, unit='D') + 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) -> pd.DataFrame: +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['month_start'] = out['business_date'].dt.to_period('M').dt.start_time + 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/tests/test_aggregation.py b/scripts/forecast/tests/test_aggregation.py index 0c1ea3f..69a845c 100644 --- a/scripts/forecast/tests/test_aggregation.py +++ b/scripts/forecast/tests/test_aggregation.py @@ -37,3 +37,27 @@ def test_bucket_to_weekly_excludes_partial_week(): 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 From a04fc23799bcc1ea4fc3cf5d62a67e353dddf48c Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:49:29 +0200 Subject: [PATCH 10/33] feat(15-10): run_all triple-loop + freshness gate (D-16) - Add GRANULARITIES = ['day','week','month'] and triple-nested model x KPI x grain loop. Each spawn now threads GRANULARITY env var. - 5 models x 2 KPIs x 3 grains = 30 spawns/refresh on full pipeline. - Freshness gate: abort cleanly (return 0) when last_actual in kpi_daily_mv is more than 8 days stale; write a pipeline_runs failure row for triage but don't fail the workflow. - Logging now includes granularity in every spawn / success / failure line. --- scripts/forecast/run_all.py | 145 ++++++++++++++++++++++++++++++------ 1 file changed, 123 insertions(+), 22 deletions(-) diff --git a/scripts/forecast/run_all.py b/scripts/forecast/run_all.py index 0ff6d72..7dc33a0 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,40 @@ 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: + 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 +219,35 @@ 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 (would need separate eval window logic). if successes > 0: print('[run_all] Running last-7-day evaluation ...') for model in models: From 947fc9fc1283c1f895e25a70aa4938b14344015b Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:56:05 +0200 Subject: [PATCH 11/33] feat(15-10): thread granularity env to all 5 model fit scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each *_fit script now reads GRANULARITY (day|week|month) from env and: - computes a grain-specific TRAIN_END (D-14): last_actual-7d / -35d / end-of-(month-5) - aggregates daily history to weekly/monthly via aggregation.bucket_to_* when grain != 'day' - picks horizon 372/57/17 and seasonal period 7/52/12 (or model-specific equivalent: Prophet weekly/yearly flags, naive_dow seasonal_key swap to ISO week-of-year / month-of-year) - sets the new 'granularity' column on every forecast_daily row - skips closed-day post-hoc zeroing for non-day grains (closed days roll into bucket sums; per-day open/closed gating no longer applies) SARIMAX/ETS/theta drop exog regressors at week/month grain — exog matrix is daily-shaped, bucket-aggregating it is out of scope for 15-10. naive_dow keeps model_name='naive_dow' (chart legend strings depend on this per Phase 15 v1's locked decisions); only the seasonal grouping key changes. --- scripts/forecast/ets_fit.py | 287 +++++++++++++++++------ scripts/forecast/naive_dow_fit.py | 372 +++++++++++++++++++++--------- scripts/forecast/prophet_fit.py | 278 +++++++++++++++------- scripts/forecast/sarimax_fit.py | 281 +++++++++++++++------- scripts/forecast/theta_fit.py | 322 ++++++++++++++++++-------- 5 files changed, 1092 insertions(+), 448 deletions(-) diff --git a/scripts/forecast/ets_fit.py b/scripts/forecast/ets_fit.py index 19a51b6..40723ec 100644 --- a/scripts/forecast/ets_fit.py +++ b/scripts/forecast/ets_fit.py @@ -1,15 +1,21 @@ -"""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 @@ -20,19 +26,53 @@ import numpy as np import pandas as pd +from dateutil.relativedelta import relativedelta from statsmodels.tsa.exponential_smoothing.ets import ETSModel 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.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 knobs (D-14). +HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +SEASONAL_PERIODS_BY_GRAIN = {'day': 7, 'week': 52, 'month': 12} + + +def _train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff (D-14).""" + if granularity == 'day': + return last_actual - timedelta(days=7) + if granularity == 'week': + return last_actual - timedelta(days=35) + if granularity == 'month': + anchor = last_actual - relativedelta(months=5) + first_of_anchor = anchor.replace(day=1) + 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.""" + if granularity == 'day': + return [run_date + timedelta(days=i + 1) for i in range(horizon)] + if granularity == 'week': + 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 = (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 _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: @@ -103,13 +143,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 +171,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 +179,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 +210,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 _upsert_rows(client, rows: list[dict]) -> int: +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) -> 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 +270,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})' + ) + + 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 - # 9. Restore target_date to str for upsert + # 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 @@ -273,17 +398,27 @@ def fit_and_write( 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() + granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) + if granularity not in HORIZON_BY_GRAIN: + print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', 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 +426,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/naive_dow_fit.py b/scripts/forecast/naive_dow_fit.py index d465d85..c619f81 100644 --- a/scripts/forecast/naive_dow_fit.py +++ b/scripts/forecast/naive_dow_fit.py @@ -1,16 +1,24 @@ -"""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 @@ -22,25 +30,72 @@ import numpy as np import pandas as pd +from dateutil.relativedelta import relativedelta 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.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 knobs (D-14). +HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} + + +def _train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff (D-14).""" + if granularity == 'day': + return last_actual - timedelta(days=7) + if granularity == 'week': + return last_actual - timedelta(days=35) + if granularity == 'month': + anchor = last_actual - relativedelta(months=5) + first_of_anchor = anchor.replace(day=1) + 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.""" + if granularity == 'day': + return [run_date + timedelta(days=i + 1) for i in range(horizon)] + if granularity == 'week': + 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 = (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 _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. - 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') @@ -53,14 +108,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 +130,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 +155,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 +184,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 +192,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 +219,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,101 +277,170 @@ 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}' + + 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' + ) + + 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') - # 10. Zero closed days post-hoc (belt-and-suspenders) - preds_df = zero_closed_days(preds_df, shop_cal) + 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}' - # 11. Restore target_date to str for upsert + 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() + granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) + if granularity not in HORIZON_BY_GRAIN: + print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', 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 +448,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..261a542 100644 --- a/scripts/forecast/prophet_fit.py +++ b/scripts/forecast/prophet_fit.py @@ -1,12 +1,20 @@ -"""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 @@ -14,27 +22,64 @@ import sys import traceback from datetime import date, datetime, timedelta, timezone +from typing import Optional import numpy as np import pandas as pd +from dateutil.relativedelta import relativedelta from prophet import Prophet 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.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 knobs (D-14). +HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +# 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'] +def _train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff (D-14).""" + if granularity == 'day': + return last_actual - timedelta(days=7) + if granularity == 'week': + return last_actual - timedelta(days=35) + if granularity == 'month': + anchor = last_actual - relativedelta(months=5) + first_of_anchor = anchor.replace(day=1) + 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.""" + if granularity == 'day': + return [run_date + timedelta(days=i + 1) for i in range(horizon)] + if granularity == 'week': + 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 = (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 _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: """Fetch kpi_daily_mv history for the given restaurant and KPI. @@ -83,52 +128,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 +190,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 +210,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 +220,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 +236,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, + }) + + n_buckets = len(train_df) + if n_buckets < 2: + raise RuntimeError( + f'Insufficient {granularity} history: {n_buckets} buckets' + ) + + pred_dates = _pred_dates_for_grain( + run_date=run_date, granularity=granularity, horizon=horizon, + ) + future_df = _build_future_df(pred_dates, None) - # 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' + m = _fit_prophet(train_df, granularity=granularity, use_regressors=False, n_buckets=n_buckets) + exog_sig = {'model': 'prophet', 'granularity': granularity, 'n_buckets': n_buckets} - # 6. Fit Prophet model - m = _fit_prophet(train_df) - print(f'[prophet_fit] Fitted Prophet for {kpi_name}') + print(f'[prophet_fit] Fitted Prophet for {kpi_name}/{granularity}') - # 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) + # 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 @@ -272,17 +364,27 @@ def fit_and_write( 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() + granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) + if granularity not in HORIZON_BY_GRAIN: + print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', 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 +392,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/sarimax_fit.py b/scripts/forecast/sarimax_fit.py index 4a5d06b..531627d 100644 --- a/scripts/forecast/sarimax_fit.py +++ b/scripts/forecast/sarimax_fit.py @@ -1,14 +1,21 @@ -"""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 @@ -21,23 +28,55 @@ import numpy as np import pandas as pd import statsmodels.api as sm +from dateutil.relativedelta import relativedelta from numpy.linalg import LinAlgError 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.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 knobs (D-14). +HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +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.""" + period = SEASONAL_PERIOD_BY_GRAIN[granularity] + return (1, 1, 1, period), (0, 1, 0, period) + + +def _train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff. + + Daily : last_actual - 7 days + Weekly: last_actual - 35 days (5 weeks back so week buckets are complete) + Monthly: end-of-month for (last_actual.month - 5 calendar months) + """ + 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 _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: """Fetch kpi_daily_mv history for the given restaurant and KPI. @@ -96,11 +135,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 +153,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 +176,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 +199,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 +209,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): @@ -176,92 +219,164 @@ def _upsert_rows(client, rows: list[dict]) -> int: return total +def _pred_dates_for_grain(*, run_date: date, granularity: str, horizon: int) -> list: + """Build list of native-cadence target_dates starting one bucket after run_date. + + Daily : run_date+1, +2, ... +HORIZON days + Weekly: next ISO Monday after run_date, then +7d steps + Monthly: first-of-month after run_date, then +1 month steps + """ + 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 fit_and_write( client, *, 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 @@ -272,17 +387,27 @@ def fit_and_write( 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() + granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) + if granularity not in HORIZON_BY_GRAIN: + print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', 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 +415,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/theta_fit.py b/scripts/forecast/theta_fit.py index 82c13c2..a4efd78 100644 --- a/scripts/forecast/theta_fit.py +++ b/scripts/forecast/theta_fit.py @@ -1,15 +1,19 @@ -"""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 @@ -20,28 +24,58 @@ import numpy as np import pandas as pd +from dateutil.relativedelta import relativedelta from statsforecast import StatsForecast from statsforecast.models import AutoTheta 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.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 knobs (D-14). +HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +SEASON_LENGTH_BY_GRAIN = {'day': 7, 'week': 52, 'month': 12} + + +def _train_end_for_grain(last_actual: date, granularity: str) -> date: + """Compute the grain-specific TRAIN_END cutoff (D-14).""" + if granularity == 'day': + return last_actual - timedelta(days=7) + if granularity == 'week': + return last_actual - timedelta(days=35) + if granularity == 'month': + anchor = last_actual - relativedelta(months=5) + first_of_anchor = anchor.replace(day=1) + 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.""" + if granularity == 'day': + return [run_date + timedelta(days=i + 1) for i in range(horizon)] + if granularity == 'week': + 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 = (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 _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 +88,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 +110,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 +135,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 +153,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 +167,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 +175,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 +203,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,102 +262,164 @@ 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}' - # 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', + 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})' + ) + + sf, _ = _fit_theta(y, season_length=season_length) + print(f'[theta_fit] Fitted AutoTheta for {kpi_name}/{granularity} on {len(y)} buckets') + + 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 - # 11. Restore target_date to str for upsert + # 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() + granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) + if granularity not in HORIZON_BY_GRAIN: + print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', 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 +427,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() From 764c445e2c3a2e49c65ed406994cd7ac0300bb8f Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 01:58:15 +0200 Subject: [PATCH 12/33] test(15-10): integration tests for run_all triple-grain loop - test_run_all_loops_over_three_granularities asserts that 1 model x 2 KPIs x 3 grains produces exactly 6 subprocess.run spawns, one per (KPI, grain) pair, each tagged with the matching GRANULARITY env var. - test_freshness_gate_aborts_on_stale_data confirms run_all returns 0 with zero spawns when last_actual is 10 days old (> 8-day threshold). Stubs the supabase package via sys.modules so the test runs offline without supabase installed (matches the local dev / CI unit-test env where the runtime client isn't required). --- .../forecast/tests/test_run_all_grain_loop.py | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 scripts/forecast/tests/test_run_all_grain_loop.py 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' From d5f9c601d3f6c52fab9a54566e0e164dd3154e92 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:03:26 +0200 Subject: [PATCH 13/33] docs(15-10): note model-order/eval/gate semantics from spec review Address spec-review concerns: - run_all.py: explain why freshness-gate uses write_failure with status= 'failure' instead of 'waiting_for_data' (the writer doesn't expose that status); document filter for triage. - run_all.py: point eval-still-daily TODO at Phase 17 (backtest gate). - sarimax_fit.py: document that the (1,1,1)/(0,1,0) order is held constant across grains per D-14 escalation note; flag month-grain over-param risk for Phase 17 to revisit. --- scripts/forecast/run_all.py | 8 +++++++- scripts/forecast/sarimax_fit.py | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/forecast/run_all.py b/scripts/forecast/run_all.py index 7dc33a0..42a9467 100644 --- a/scripts/forecast/run_all.py +++ b/scripts/forecast/run_all.py @@ -195,6 +195,11 @@ def main( 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: @@ -247,7 +252,8 @@ def main( # Evaluate last-7-day forecast accuracy for each model/KPI # 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 (would need separate eval window logic). + # 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 531627d..68b9cee 100644 --- a/scripts/forecast/sarimax_fit.py +++ b/scripts/forecast/sarimax_fit.py @@ -50,7 +50,15 @@ def _seasonal_orders(granularity: str) -> tuple: - """Return (primary, fallback) seasonal_order tuples for the given grain.""" + """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) From 61720608cf94ff107aa2c0da9bcf1cc0d7a088a4 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:13:48 +0200 Subject: [PATCH 14/33] refactor(15-10): extract grain helpers + document closed-day assumption (I-1, I-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review I-1: HORIZON_BY_GRAIN, _train_end_for_grain, and _pred_dates_for_grain were duplicated verbatim across 5 model fit scripts (sarimax, prophet, ets, theta, naive_dow). Math is grain-driven, not model-driven, so there is no parallel-evolution argument for keeping copies. Extract to scripts/forecast/grain_helpers.py — single source of truth, will also be imported by /api/forecast in plan 15-11. Adds parse_granularity_env() so the GRANULARITY env-var validation block in each fit script's __main__ collapses to one line. Behavior matches the previous strip-empty-defaults-to-day semantics. I-2: train_end_for_grain docstring now explicitly explains why the gap between TRAIN_END and the first forecast bucket can be ~35 days (week) or ~5 months (month) — it is intentional, sized so each training bucket is fully complete. I-3: closed_days.py module docstring now documents the load-bearing "missing date = open" assumption that produces the smooth 372-day forecast curve. Also drops now-unused dateutil.relativedelta and timedelta imports from the 5 fit scripts. Tests: 24 passed, 2 skipped (unchanged from pre-refactor baseline). --- scripts/forecast/closed_days.py | 35 ++++++++++- scripts/forecast/ets_fit.py | 55 +++++------------ scripts/forecast/grain_helpers.py | 98 +++++++++++++++++++++++++++++++ scripts/forecast/naive_dow_fit.py | 55 +++++------------ scripts/forecast/prophet_fit.py | 55 +++++------------ scripts/forecast/sarimax_fit.py | 73 +++++------------------ scripts/forecast/theta_fit.py | 55 +++++------------ 7 files changed, 205 insertions(+), 221 deletions(-) create mode 100644 scripts/forecast/grain_helpers.py 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 40723ec..4736f4d 100644 --- a/scripts/forecast/ets_fit.py +++ b/scripts/forecast/ets_fit.py @@ -22,17 +22,22 @@ 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 -from dateutil.relativedelta import relativedelta from statsmodels.tsa.exponential_smoothing.ets import ETSModel 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 --- @@ -40,41 +45,10 @@ STEP_NAME = 'forecast_ets' CHUNK_SIZE = 100 -# 15-10: per-grain knobs (D-14). -HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +# 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 _train_end_for_grain(last_actual: date, granularity: str) -> date: - """Compute the grain-specific TRAIN_END cutoff (D-14).""" - if granularity == 'day': - return last_actual - timedelta(days=7) - if granularity == 'week': - return last_actual - timedelta(days=35) - if granularity == 'month': - anchor = last_actual - relativedelta(months=5) - first_of_anchor = anchor.replace(day=1) - 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.""" - if granularity == 'day': - return [run_date + timedelta(days=i + 1) for i in range(horizon)] - if granularity == 'week': - 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 = (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 _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. @@ -282,7 +256,7 @@ def fit_and_write( # 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) + 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}' @@ -306,7 +280,7 @@ def fit_and_write( 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( + all_pred_dates = pred_dates_for_grain( run_date=run_date, granularity='day', horizon=horizon, ) shop_cal = _fetch_shop_calendar( @@ -361,7 +335,7 @@ def fit_and_write( 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( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity=granularity, horizon=horizon, ) sim_raw = result.simulate( @@ -398,13 +372,14 @@ def fit_and_write( 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() - granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) - if granularity not in HORIZON_BY_GRAIN: - print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', file=sys.stderr) + 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) 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 c619f81..2fb964e 100644 --- a/scripts/forecast/naive_dow_fit.py +++ b/scripts/forecast/naive_dow_fit.py @@ -25,17 +25,22 @@ 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 import pandas as pd -from dateutil.relativedelta import relativedelta 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 --- @@ -43,38 +48,7 @@ STEP_NAME = 'forecast_naive_dow' CHUNK_SIZE = 100 -# 15-10: per-grain knobs (D-14). -HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} - - -def _train_end_for_grain(last_actual: date, granularity: str) -> date: - """Compute the grain-specific TRAIN_END cutoff (D-14).""" - if granularity == 'day': - return last_actual - timedelta(days=7) - if granularity == 'week': - return last_actual - timedelta(days=35) - if granularity == 'month': - anchor = last_actual - relativedelta(months=5) - first_of_anchor = anchor.replace(day=1) - 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.""" - if granularity == 'day': - return [run_date + timedelta(days=i + 1) for i in range(horizon)] - if granularity == 'week': - 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 = (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}') +# 15-10: per-grain knob (D-14). HORIZON_BY_GRAIN now lives in grain_helpers. def _seasonal_key(d: date, granularity: str) -> int: @@ -285,7 +259,7 @@ def fit_and_write( # 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) + 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}' @@ -313,7 +287,7 @@ def fit_and_write( ) print(f'[naive_dow_fit] DoW means computed for {kpi_name}: {means}') - all_pred_dates = _pred_dates_for_grain( + all_pred_dates = pred_dates_for_grain( run_date=run_date, granularity='day', horizon=horizon, ) shop_cal = _fetch_shop_calendar( @@ -379,7 +353,7 @@ def fit_and_write( ) print(f'[naive_dow_fit] {granularity} seasonal means for {kpi_name}: {len(means)} keys') - pred_dates = _pred_dates_for_grain( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity=granularity, horizon=horizon, ) @@ -420,13 +394,14 @@ def fit_and_write( 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() - granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) - if granularity not in HORIZON_BY_GRAIN: - print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', file=sys.stderr) + 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) diff --git a/scripts/forecast/prophet_fit.py b/scripts/forecast/prophet_fit.py index 261a542..b4c6c45 100644 --- a/scripts/forecast/prophet_fit.py +++ b/scripts/forecast/prophet_fit.py @@ -21,12 +21,11 @@ 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 -from dateutil.relativedelta import relativedelta from prophet import Prophet from scripts.forecast.db import make_client @@ -34,6 +33,12 @@ 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 --- @@ -41,8 +46,7 @@ STEP_NAME = 'forecast_prophet' CHUNK_SIZE = 100 -# 15-10: per-grain knobs (D-14). -HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +# 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} @@ -50,36 +54,6 @@ _REGRESSOR_COLS = [c for c in EXOG_COLUMNS if c != 'weather_source'] -def _train_end_for_grain(last_actual: date, granularity: str) -> date: - """Compute the grain-specific TRAIN_END cutoff (D-14).""" - if granularity == 'day': - return last_actual - timedelta(days=7) - if granularity == 'week': - return last_actual - timedelta(days=35) - if granularity == 'month': - anchor = last_actual - relativedelta(months=5) - first_of_anchor = anchor.replace(day=1) - 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.""" - if granularity == 'day': - return [run_date + timedelta(days=i + 1) for i in range(horizon)] - if granularity == 'week': - 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 = (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 _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: """Fetch kpi_daily_mv history for the given restaurant and KPI. @@ -247,7 +221,7 @@ def fit_and_write( # 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) + 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}' @@ -273,7 +247,7 @@ def fit_and_write( train_df = _build_prophet_df(history, X_fit) - pred_dates = _pred_dates_for_grain( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity='day', horizon=horizon, ) pred_start = pred_dates[0] @@ -312,7 +286,7 @@ def fit_and_write( f'Insufficient {granularity} history: {n_buckets} buckets' ) - pred_dates = _pred_dates_for_grain( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity=granularity, horizon=horizon, ) future_df = _build_future_df(pred_dates, None) @@ -364,13 +338,14 @@ def fit_and_write( 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() - granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) - if granularity not in HORIZON_BY_GRAIN: - print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', file=sys.stderr) + 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) diff --git a/scripts/forecast/sarimax_fit.py b/scripts/forecast/sarimax_fit.py index 68b9cee..a047275 100644 --- a/scripts/forecast/sarimax_fit.py +++ b/scripts/forecast/sarimax_fit.py @@ -22,13 +22,12 @@ 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 import statsmodels.api as sm -from dateutil.relativedelta import relativedelta from numpy.linalg import LinAlgError from scripts.forecast.db import make_client @@ -36,6 +35,12 @@ 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 --- @@ -44,8 +49,7 @@ STEP_NAME = 'forecast_sarimax' CHUNK_SIZE = 100 -# 15-10: per-grain knobs (D-14). -HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +# 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} @@ -63,29 +67,6 @@ def _seasonal_orders(granularity: str) -> tuple: return (1, 1, 1, period), (0, 1, 0, period) -def _train_end_for_grain(last_actual: date, granularity: str) -> date: - """Compute the grain-specific TRAIN_END cutoff. - - Daily : last_actual - 7 days - Weekly: last_actual - 35 days (5 weeks back so week buckets are complete) - Monthly: end-of-month for (last_actual.month - 5 calendar months) - """ - 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 _fetch_history(client, *, restaurant_id: str, kpi_name: str) -> pd.DataFrame: """Fetch kpi_daily_mv history for the given restaurant and KPI. @@ -227,31 +208,6 @@ def _upsert_rows(client, rows: list) -> int: return total -def _pred_dates_for_grain(*, run_date: date, granularity: str, horizon: int) -> list: - """Build list of native-cadence target_dates starting one bucket after run_date. - - Daily : run_date+1, +2, ... +HORIZON days - Weekly: next ISO Monday after run_date, then +7d steps - Monthly: first-of-month after run_date, then +1 month steps - """ - 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 fit_and_write( client, *, @@ -269,7 +225,7 @@ def fit_and_write( # 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) + 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}' @@ -297,7 +253,7 @@ def fit_and_write( history_dates = set(history['date']) X_fit = X_fit.loc[X_fit.index.isin(history_dates)] - pred_dates = _pred_dates_for_grain( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity='day', horizon=horizon, ) pred_start = pred_dates[0] @@ -344,7 +300,7 @@ def fit_and_write( 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( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity=granularity, horizon=horizon, ) samples_raw = result.simulate( @@ -395,13 +351,14 @@ def fit_and_write( 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() - granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) - if granularity not in HORIZON_BY_GRAIN: - print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', file=sys.stderr) + 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) diff --git a/scripts/forecast/theta_fit.py b/scripts/forecast/theta_fit.py index a4efd78..e5d4ff0 100644 --- a/scripts/forecast/theta_fit.py +++ b/scripts/forecast/theta_fit.py @@ -20,11 +20,10 @@ 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 -from dateutil.relativedelta import relativedelta from statsforecast import StatsForecast from statsforecast.models import AutoTheta @@ -32,6 +31,12 @@ 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 --- @@ -39,41 +44,10 @@ STEP_NAME = 'forecast_theta' CHUNK_SIZE = 100 -# 15-10: per-grain knobs (D-14). -HORIZON_BY_GRAIN = {'day': 372, 'week': 57, 'month': 17} +# 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 _train_end_for_grain(last_actual: date, granularity: str) -> date: - """Compute the grain-specific TRAIN_END cutoff (D-14).""" - if granularity == 'day': - return last_actual - timedelta(days=7) - if granularity == 'week': - return last_actual - timedelta(days=35) - if granularity == 'month': - anchor = last_actual - relativedelta(months=5) - first_of_anchor = anchor.replace(day=1) - 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.""" - if granularity == 'day': - return [run_date + timedelta(days=i + 1) for i in range(horizon)] - if granularity == 'week': - 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 = (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 _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 = ( @@ -271,7 +245,7 @@ def fit_and_write( # 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) + 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}' @@ -294,7 +268,7 @@ def fit_and_write( 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( + all_pred_dates = pred_dates_for_grain( run_date=run_date, granularity='day', horizon=horizon, ) shop_cal = _fetch_shop_calendar( @@ -356,7 +330,7 @@ def fit_and_write( sf, _ = _fit_theta(y, season_length=season_length) print(f'[theta_fit] Fitted AutoTheta for {kpi_name}/{granularity} on {len(y)} buckets') - pred_dates = _pred_dates_for_grain( + pred_dates = pred_dates_for_grain( run_date=run_date, granularity=granularity, horizon=horizon, ) pred_df = sf.forecast(h=horizon, fitted=True) @@ -399,13 +373,14 @@ def fit_and_write( 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() - granularity = os.environ.get('GRANULARITY', 'day').strip() or 'day' 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) - if granularity not in HORIZON_BY_GRAIN: - print(f'ERROR: invalid GRANULARITY {granularity!r}; expected one of {list(HORIZON_BY_GRAIN)}', file=sys.stderr) + 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) From 65ba1a13b558229ffaeb6686b1f8ca75b63915cf Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:18:43 +0200 Subject: [PATCH 15/33] feat(15-11): drop forecastResampling.ts; slim forecastValidation (D-14) --- src/lib/forecastResampling.ts | 76 --------------------------- src/lib/forecastValidation.ts | 46 +++++----------- tests/unit/forecastResampling.test.ts | 71 ------------------------- tests/unit/forecastValidation.test.ts | 46 +++++----------- 4 files changed, 28 insertions(+), 211 deletions(-) delete mode 100644 src/lib/forecastResampling.ts delete mode 100644 tests/unit/forecastResampling.test.ts diff --git a/src/lib/forecastResampling.ts b/src/lib/forecastResampling.ts deleted file mode 100644 index 1cdae5e..0000000 --- a/src/lib/forecastResampling.ts +++ /dev/null @@ -1,76 +0,0 @@ -// src/lib/forecastResampling.ts -// Phase 14 C-05 / D-04: server-side resampling of daily forecast rows -// into week or month grains. Aggregation is mean(yhat_mean), mean(yhat_lower), -// mean(yhat_upper) per (model_name, bucket_start_date). horizon_days collapses -// to the smallest horizon in the bucket (the earliest-target-date row drives it). -// -// Why mean and not sum: yhat is already a per-day expected value. Summing -// would imply "weekly total" which is a different KPI; mean preserves the -// "expected daily value" semantic so the y-axis stays consistent across grains. -// -// ISO week start = Monday. date-fns startOfWeek({ weekStartsOn: 1 }). -import { startOfWeek, startOfMonth, format } from 'date-fns'; -import type { Granularity } from './forecastValidation'; - -export type ForecastRowDaily = { - target_date: string; // YYYY-MM-DD - model_name: string; - yhat_mean: number; - yhat_lower: number; - yhat_upper: number; - horizon_days: number; -}; - -export type ForecastRowOut = ForecastRowDaily; - -export function resampleByGranularity( - rows: readonly ForecastRowDaily[], - granularity: Granularity -): ForecastRowOut[] { - if (granularity === 'day') return rows.slice(); - - const bucketKey = (dateStr: string): string => { - const d = new Date(dateStr + 'T00:00:00Z'); - const start = granularity === 'week' - ? startOfWeek(d, { weekStartsOn: 1 }) - : startOfMonth(d); - return format(start, 'yyyy-MM-dd'); - }; - - type Acc = { sumMean: number; sumLower: number; sumUpper: number; n: number; minHorizon: number }; - const buckets = new Map(); // key = `${model_name}|${bucket_date}` - - for (const r of rows) { - const key = `${r.model_name}|${bucketKey(r.target_date)}`; - const cur = buckets.get(key); - if (cur) { - cur.sumMean += r.yhat_mean; - cur.sumLower += r.yhat_lower; - cur.sumUpper += r.yhat_upper; - cur.n += 1; - if (r.horizon_days < cur.minHorizon) cur.minHorizon = r.horizon_days; - } else { - buckets.set(key, { - sumMean: r.yhat_mean, - sumLower: r.yhat_lower, - sumUpper: r.yhat_upper, - n: 1, - minHorizon: r.horizon_days - }); - } - } - - const out: ForecastRowOut[] = []; - for (const [key, acc] of buckets) { - const [model_name, target_date] = key.split('|'); - out.push({ - target_date, - model_name, - yhat_mean: acc.sumMean / acc.n, - yhat_lower: acc.sumLower / acc.n, - yhat_upper: acc.sumUpper / acc.n, - horizon_days: acc.minHorizon - }); - } - return out; -} diff --git a/src/lib/forecastValidation.ts b/src/lib/forecastValidation.ts index 97b1096..59cf43d 100644 --- a/src/lib/forecastValidation.ts +++ b/src/lib/forecastValidation.ts @@ -1,47 +1,29 @@ // src/lib/forecastValidation.ts -// Phase 15 D-11 — horizon × granularity clamp matrix. -// Mirrors the Phase 10 D-17 cohort grain clamp pattern. The endpoint -// rejects illegal combos with HTTP 400 so an attacker can't ask for -// 365 daily bars (1px each at 375px and 365 subrequests cost on CF). +// 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 type Horizon = typeof HORIZON_DAYS[number]; export const GRANULARITIES = ['day', 'week', 'month'] as const; export type Granularity = typeof GRANULARITIES[number]; -export function parseHorizon(raw: string | null): Horizon | null { +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 as Horizon) : null; + 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; } - -// D-11 clamp matrix: which (horizon, granularity) combos the endpoint accepts. -// 7d → day only (35 daily bars max — readable on 375px) -// 5w → day | week -// 4mo → week | month (no day — 120 daily bars overflow 375px) -// 1yr → month only (no day, no week — 365 day or 52 week bars unreadable) -const VALID: Record> = { - 7: new Set(['day']), - 35: new Set(['day', 'week']), - 120: new Set(['week', 'month']), - 365: new Set(['month']) -}; - -export function isValidCombo(horizon: Horizon, granularity: Granularity): boolean { - return VALID[horizon].has(granularity); -} - -// Default granularity for each horizon — used when the client omits ?granularity=. -// Picks the smallest valid bucket (day where possible, week for 4mo, month for 1yr). -export const DEFAULT_GRANULARITY: Record = { - 7: 'day', - 35: 'day', - 120: 'week', - 365: 'month' -}; diff --git a/tests/unit/forecastResampling.test.ts b/tests/unit/forecastResampling.test.ts deleted file mode 100644 index fb5362b..0000000 --- a/tests/unit/forecastResampling.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// tests/unit/forecastResampling.test.ts -// Phase 14 C-05 / D-04: server resamples daily forecast rows into week / month. -// Client never sees raw 200-path arrays — only mean + lower + upper per bucket. -// -// Resampling rule for week: bucket key = ISO Monday-start date of target_date. -// Resampling rule for month: bucket key = first-of-month date of target_date. -// Aggregation: mean of yhat_mean, mean of yhat_lower, mean of yhat_upper. -import { describe, it, expect } from 'vitest'; -import { resampleByGranularity, type ForecastRowDaily, type ForecastRowOut } from '../../src/lib/forecastResampling'; - -const sarimaxRow = (date: string, mean: number, lower: number, upper: number): ForecastRowDaily => ({ - target_date: date, model_name: 'sarimax', - yhat_mean: mean, yhat_lower: lower, yhat_upper: upper, horizon_days: 1 -}); - -describe('resampleByGranularity', () => { - it('day passthrough — returns input rows unchanged', () => { - const rows = [sarimaxRow('2026-05-04', 100, 90, 110), sarimaxRow('2026-05-05', 200, 180, 220)]; - expect(resampleByGranularity(rows, 'day')).toEqual(rows); - }); - - it('week bucket — Mon 2026-05-04 + Tue 2026-05-05 collapse to one row keyed 2026-05-04', () => { - const rows = [sarimaxRow('2026-05-04', 100, 90, 110), sarimaxRow('2026-05-05', 200, 180, 220)]; - const out = resampleByGranularity(rows, 'week'); - expect(out.length).toBe(1); - expect(out[0].target_date).toBe('2026-05-04'); - expect(out[0].yhat_mean).toBeCloseTo(150, 6); - expect(out[0].yhat_lower).toBeCloseTo(135, 6); - expect(out[0].yhat_upper).toBeCloseTo(165, 6); - }); - - it('month bucket — 2026-05-15 + 2026-05-31 + 2026-06-01 yield two rows keyed 2026-05-01 and 2026-06-01', () => { - const rows = [ - sarimaxRow('2026-05-15', 100, 90, 110), - sarimaxRow('2026-05-31', 200, 180, 220), - sarimaxRow('2026-06-01', 300, 270, 330) - ]; - const out = resampleByGranularity(rows, 'month'); - expect(out.map(r => r.target_date).sort()).toEqual(['2026-05-01', '2026-06-01']); - const may = out.find(r => r.target_date === '2026-05-01')!; - const jun = out.find(r => r.target_date === '2026-06-01')!; - expect(may.yhat_mean).toBeCloseTo(150, 6); - expect(jun.yhat_mean).toBeCloseTo(300, 6); - }); - - it('preserves model_name during resampling — buckets per (model, period)', () => { - const rows: ForecastRowDaily[] = [ - sarimaxRow('2026-05-04', 100, 90, 110), - { target_date: '2026-05-04', model_name: 'prophet', yhat_mean: 80, yhat_lower: 70, yhat_upper: 90, horizon_days: 1 } - ]; - const out = resampleByGranularity(rows, 'week'); - expect(out.length).toBe(2); - const models = out.map(r => r.model_name).sort(); - expect(models).toEqual(['prophet', 'sarimax']); - }); - - it('week bucket on a Sunday rolls back to the prior Monday (ISO week start)', () => { - const rows = [sarimaxRow('2026-05-10', 100, 90, 110)]; // 2026-05-10 is a Sunday - const out = resampleByGranularity(rows, 'week'); - expect(out[0].target_date).toBe('2026-05-04'); // ISO Monday of 2026-W19 - }); - - it('horizon_days on resampled rows is the smallest horizon in the bucket', () => { - const rows = [ - sarimaxRow('2026-05-04', 100, 90, 110), // horizon_days: 1 - { ...sarimaxRow('2026-05-05', 200, 180, 220), horizon_days: 2 } - ]; - const out = resampleByGranularity(rows, 'week'); - expect(out[0].horizon_days).toBe(1); - }); -}); diff --git a/tests/unit/forecastValidation.test.ts b/tests/unit/forecastValidation.test.ts index 024c5cc..03846d2 100644 --- a/tests/unit/forecastValidation.test.ts +++ b/tests/unit/forecastValidation.test.ts @@ -1,17 +1,17 @@ // tests/unit/forecastValidation.test.ts -// Phase 15 D-11 — validate ?horizon= + ?granularity= against the clamp matrix: -// 7d → day -// 5w → day | week -// 4mo → week | month -// 1yr → month +// 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, - isValidCombo, - type Horizon, - type Granularity, - HORIZON_DAYS + HORIZON_DAYS, + GRANULARITIES } from '../../src/lib/forecastValidation'; describe('parseHorizon', () => { @@ -35,29 +35,11 @@ describe('parseGranularity', () => { }); }); -describe('isValidCombo (D-11 clamp matrix)', () => { - const valid: Array<[Horizon, Granularity]> = [ - [7, 'day'], - [35, 'day'], [35, 'week'], - [120, 'week'], [120, 'month'], - [365, 'month'] - ]; - const invalid: Array<[Horizon, Granularity]> = [ - [7, 'week'], [7, 'month'], - [35, 'month'], - [120, 'day'], - [365, 'day'], [365, 'week'] - ]; - it.each(valid)('accepts horizon=%i granularity=%s', (h, g) => { - expect(isValidCombo(h, g)).toBe(true); - }); - it.each(invalid)('rejects horizon=%i granularity=%s', (h, g) => { - expect(isValidCombo(h, g)).toBe(false); - }); -}); - -describe('HORIZON_DAYS constants', () => { - it('exposes 7/35/120/365 (FUI-03)', () => { +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']); + }); }); From 53e442292a06bfdd35c542663d16ed2a6b75ed59 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:22:09 +0200 Subject: [PATCH 16/33] feat(15-11): /api/forecast native-grain query + ?kpi= param + backtest actuals --- src/routes/api/forecast/+server.ts | 183 ++++++++++++++++------------- tests/unit/apiEndpoints.test.ts | 126 ++++++++++++++++---- 2 files changed, 209 insertions(+), 100 deletions(-) diff --git a/src/routes/api/forecast/+server.ts b/src/routes/api/forecast/+server.ts index 0a1aa2a..ebba9d3 100644 --- a/src/routes/api/forecast/+server.ts +++ b/src/routes/api/forecast/+server.ts @@ -1,38 +1,35 @@ // src/routes/api/forecast/+server.ts -// Phase 15 D-06 / D-09 / D-11 / FUI-07. -// Deferred endpoint for RevenueForecastCard. Long-format rows + sibling -// events array + last_run timestamp. Server-side resampling per granularity -// per Phase 14 C-05 / D-04 (client never receives raw 200-path arrays). +// 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'). +// 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). Holidays / school_holidays / recurring_events / -// transit_alerts are global tables (no tenant scoping needed; they're -// public knowledge). +// migration 0049). // Cache-Control: private, no-store — prevents CDN cross-tenant leakage. -// CF Pages 50-subrequest budget (Phase 11 D-06): this handler issues 6 -// parallel Supabase queries (~6 subrequests) — well under the cap. -// -// Validation: ?horizon= must be in {7,35,120,365}; ?granularity= must -// pair legally per D-11. Illegal combos → 400 (no DB call). +// 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 { - parseHorizon, - parseGranularity, - isValidCombo, - DEFAULT_GRANULARITY, - type Granularity -} from '$lib/forecastValidation'; -import { resampleByGranularity, type ForecastRowDaily } from '$lib/forecastResampling'; +import { parseGranularity, type Granularity } from '$lib/forecastValidation'; import { clampEvents, type ForecastEvent } from '$lib/forecastEventClamp'; -import { addDays, format } from 'date-fns'; +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; @@ -42,6 +39,7 @@ type ForecastViewRow = { 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 }; @@ -50,48 +48,59 @@ type PipelineRunRow = { step_name: string; status: string; finished_at: stri 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 horizon = parseHorizon(url.searchParams.get('horizon')); - if (horizon === null) { - return json({ error: 'invalid horizon (must be 7, 35, 120, or 365)' }, { status: 400, headers: NO_STORE }); - } - const rawGran = url.searchParams.get('granularity'); - const granularity: Granularity = - rawGran === null - ? DEFAULT_GRANULARITY[horizon] - : (parseGranularity(rawGran) ?? ('__INVALID__' as Granularity)); - if (!isValidCombo(horizon, granularity)) { - return json( - { error: `invalid (horizon=${horizon}, granularity=${rawGran}) combo per D-11 clamp` }, - { status: 400, 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 }); } - // Window: today → today + horizon days. Use UTC for boundary stability. - const today = format(new Date(), 'yyyy-MM-dd'); - const horizonEnd = format(addDays(new Date(), horizon), 'yyyy-MM-dd'); + 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,yhat,yhat_lower,yhat_upper,horizon_days,actual_value,forecast_track,kpi_name') - .eq('kpi_name', 'revenue_eur') + .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') - .gte('target_date', today) - .lte('target_date', horizonEnd) + .eq('granularity', granularity) .order('target_date', { ascending: true }) ), fetchAll(() => locals.supabase .from('holidays') .select('date,name,country_code,subdiv_code') - .gte('date', today) - .lte('date', horizonEnd) + .gte('date', todayStr) + .lte('date', eventsEnd) .or('subdiv_code.is.null,subdiv_code.eq.BE') ), fetchAll(() => @@ -99,22 +108,22 @@ export const GET: RequestHandler = async ({ locals, url }) => { .from('school_holidays') .select('state_code,block_name,start_date,end_date') .eq('state_code', 'BE') - .gte('start_date', today) - .lte('start_date', horizonEnd) + .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', today) - .lte('start_date', horizonEnd) + .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', today) - .lte('pub_date', horizonEnd) + .gte('pub_date', todayStr) + .lte('pub_date', eventsEnd) ), fetchAll(() => locals.supabase @@ -125,38 +134,43 @@ export const GET: RequestHandler = async ({ locals, url }) => { ) ]); - // Map view rows -> daily-rate output shape (yhat -> yhat_mean). - const dailyRows: ForecastRowDaily[] = 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 + // 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 })); - const rows = resampleByGranularity(dailyRows, granularity); - - // Merge actual_value back as a separate map keyed by target_date so the - // client can render historical vs forecast on the same axis. The view's - // actual_value is non-null only for past dates; future dates remain null. - const actualByDate = new Map(); - for (const r of forecastRows) { - if (r.actual_value !== null && !actualByDate.has(r.target_date)) { - actualByDate.set(r.target_date, r.actual_value); - } - } - // Build events sibling array. + // 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 })), + ...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 })) + ...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. 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. + // 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; @@ -166,10 +180,19 @@ export const GET: RequestHandler = async ({ locals, url }) => { return json( { - rows, - actuals: Array.from(actualByDate, ([date, value]) => ({ date, value })), + 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 + last_run, + kpi, + granularity }, { headers: NO_STORE } ); diff --git a/tests/unit/apiEndpoints.test.ts b/tests/unit/apiEndpoints.test.ts index 1ac3203..d88bb99 100644 --- a/tests/unit/apiEndpoints.test.ts +++ b/tests/unit/apiEndpoints.test.ts @@ -407,12 +407,17 @@ describe('/api/repeater-lifetime', () => { }); // -------------------- /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, @@ -421,27 +426,45 @@ describe('/api/forecast', () => { 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 ?horizon=7&granularity=day returns 200 with rows + events + last_run', async () => { + 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/?horizon=7&granularity=day')); + 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', @@ -450,56 +473,119 @@ describe('/api/forecast', () => { 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/?horizon=7&granularity=day')); + 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: [], holidays: [], school_holidays: [], + 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/?horizon=7&granularity=day')); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=day')); expect(res.headers.get('cache-control')).toBe('private, no-store'); }); - it('illegal combo (horizon=365 granularity=day) returns 400 and never touches supabase', async () => { + it('missing granularity returns 400', async () => { const state = freshState(); - const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?horizon=365&granularity=day')); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/')); expect(res.status).toBe(400); expect(state.fromSpy).not.toHaveBeenCalled(); }); - it('missing horizon returns 400', async () => { + it('invalid granularity returns 400', async () => { const state = freshState(); - const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/')); + const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?granularity=hour')); expect(res.status).toBe(400); + expect(state.fromSpy).not.toHaveBeenCalled(); }); - it('omitted granularity falls back to DEFAULT_GRANULARITY for the horizon', async () => { - const state = freshState({ - forecast_with_actual_v: [fcastRow], holidays: [], school_holidays: [], - recurring_events: [], transit_alerts: [], pipeline_runs_status_v: [pipeRow] - }); - const res = await forecastGET(mkEvent(mkLocalsAuthed(state), 'http://x/?horizon=7')); - expect(res.status).toBe(200); + 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/?horizon=120&granularity=week')); + 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'); @@ -510,14 +596,14 @@ describe('/api/forecast', () => { it('last_run is the finished_at of the latest forecast_sarimax pipeline_runs row', async () => { const state = freshState({ - forecast_with_actual_v: [], holidays: [], school_holidays: [], + 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/?horizon=7&granularity=day')); + 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'); }); @@ -525,7 +611,7 @@ describe('/api/forecast', () => { 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/?horizon=7&granularity=day')); + 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'); }); From 4e272190775e4238dbe8f6efc8a1fbee360c5d83 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:32:06 +0200 Subject: [PATCH 17/33] feat(15-12): CalendarRevenueCard forecast overlay (D-15/D-17/D-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-model forecast lines + low-opacity CI bands on top of the visit-seq stacked bars in CalendarRevenueCard. Inline ForecastLegend chip row toggles models; default visible = {sarimax, naive_dow}. Scale strategy: switched from implicit scaleBand to scaleTime + xInterval={timeDay|timeMonday|timeMonth} so bars and forecast splines share the same x-axis. LayerChart's Bar.svelte handles xInterval bandwidth via interval.floor()/offset(). xDomain extended to today + 365d so the forecast horizon lives in the empty space to the right of the last bar; chartW grows proportionally for horizontal scroll. D-17 Option B: toggling a model removes BOTH its line and CI band (seriesByModel filters by visibleModels; both and each-blocks iterate that map). naive_dow renders dashed gray at stroke-width=1; smart models solid 2px. CI bands at fillOpacity=0.06 prevent visual mush at 375px. forecastData fetched once per grain change via clientFetch with lastFetchedGrain guard to prevent reactive loops. yhat_mean is in EUR (per Phase 14 schema) — bars also display in EUR after the existing /100 mapping; no extra divisor needed. Tests: 11 new artifact assertions in tests/unit/CalendarCards.test.ts (jsdom can't render LayerChart; e2e suite covers visual gate). Refs: docs/superpowers/plans/* phase 15 v2 plan 15-12 --- src/lib/components/CalendarRevenueCard.svelte | 193 ++++++++++++++++-- tests/unit/CalendarCards.test.ts | 71 +++++++ 2 files changed, 248 insertions(+), 16 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index 23cd108..e4eb2c4 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -1,18 +1,34 @@ @@ -95,10 +219,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/tests/unit/CalendarCards.test.ts b/tests/unit/CalendarCards.test.ts index 6fd1736..6bc5b91 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'), From 73e4984069534d3f00a8305b894190feeed99104 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:37:10 +0200 Subject: [PATCH 18/33] feat(15-13): CalendarCountsCard forecast overlay (D-15/D-17/D-18) --- src/lib/components/CalendarCountsCard.svelte | 192 +++++++++++++++++-- tests/unit/CalendarCards.test.ts | 81 ++++++++ 2 files changed, 257 insertions(+), 16 deletions(-) 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/tests/unit/CalendarCards.test.ts b/tests/unit/CalendarCards.test.ts index 6bc5b91..e9f8217 100644 --- a/tests/unit/CalendarCards.test.ts +++ b/tests/unit/CalendarCards.test.ts @@ -227,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/); + }); +}); From 02040f0bbe9965b1b9eb56fe69e3d8f21c28f3fc Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:39:52 +0200 Subject: [PATCH 19/33] =?UTF-8?q?feat(15-14):=20delete=20HorizonToggle=20(?= =?UTF-8?q?D-14=20makes=20it=20redundant=20=E2=80=94=20global=20GrainToggl?= =?UTF-8?q?e=20drives=20grain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/HorizonToggle.svelte | 70 ---------------- src/lib/i18n/messages.ts | 35 -------- tests/unit/HorizonToggle.test.ts | 107 ------------------------ 3 files changed, 212 deletions(-) delete mode 100644 src/lib/components/HorizonToggle.svelte delete mode 100644 tests/unit/HorizonToggle.test.ts diff --git a/src/lib/components/HorizonToggle.svelte b/src/lib/components/HorizonToggle.svelte deleted file mode 100644 index faaf5ce..0000000 --- a/src/lib/components/HorizonToggle.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - - -
- {#each options as opt (opt.value)} - - {/each} -
diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index 24eff9c..a41cd59 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -22,13 +22,6 @@ const en = { grain_month: 'Month', grain_selector_aria: 'Grain selector', - // --- Horizon toggle (Phase 15 FUI-03) ---------------------------------- - horizon_7d: '7d', - horizon_5w: '5w', - horizon_4mo: '4mo', - horizon_1yr: '1yr', - horizon_selector_aria: 'Forecast horizon selector', - // --- Forecast legend (Phase 15 D-04 / FUI-02) -------------------------- legend_aria: 'Forecast model legend', legend_model_sarimax: 'SARIMAX', @@ -212,13 +205,6 @@ const de: Record = { grain_month: 'Monat', grain_selector_aria: 'Zeitraster-Auswahl', - // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN - horizon_7d: '7d', - horizon_5w: '5w', - horizon_4mo: '4mo', - horizon_1yr: '1yr', - horizon_selector_aria: 'Forecast horizon selector', - // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN legend_aria: 'Forecast model legend', legend_model_sarimax: 'SARIMAX', @@ -393,13 +379,6 @@ const ja: Record = { grain_month: '月', grain_selector_aria: '期間粒度の選択', - // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN - horizon_7d: '7d', - horizon_5w: '5w', - horizon_4mo: '4mo', - horizon_1yr: '1yr', - horizon_selector_aria: 'Forecast horizon selector', - // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN legend_aria: 'Forecast model legend', legend_model_sarimax: 'SARIMAX', @@ -573,13 +552,6 @@ const es: Record = { grain_month: 'Mes', grain_selector_aria: 'Selector de granularidad', - // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN - horizon_7d: '7d', - horizon_5w: '5w', - horizon_4mo: '4mo', - horizon_1yr: '1yr', - horizon_selector_aria: 'Forecast horizon selector', - // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN legend_aria: 'Forecast model legend', legend_model_sarimax: 'SARIMAX', @@ -754,13 +726,6 @@ const fr: Record = { grain_month: 'Mois', grain_selector_aria: 'Sélecteur de granularité', - // Horizon toggle (Phase 15 FUI-03) — placeholder copy mirrors EN - horizon_7d: '7d', - horizon_5w: '5w', - horizon_4mo: '4mo', - horizon_1yr: '1yr', - horizon_selector_aria: 'Forecast horizon selector', - // Forecast legend (Phase 15 D-04 / FUI-02) — placeholder copy mirrors EN legend_aria: 'Forecast model legend', legend_model_sarimax: 'SARIMAX', diff --git a/tests/unit/HorizonToggle.test.ts b/tests/unit/HorizonToggle.test.ts deleted file mode 100644 index c367738..0000000 --- a/tests/unit/HorizonToggle.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// @vitest-environment jsdom -// tests/unit/HorizonToggle.test.ts -// Phase 15 FUI-03 — 4-chip selector. Default 7d. Click emits both -// onhorizonchange(horizon) and ongranularitychange(default-grain-for-horizon). -import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; -import '@testing-library/jest-dom/vitest'; -import { render, fireEvent, cleanup } from '@testing-library/svelte'; -import HorizonToggle from '../../src/lib/components/HorizonToggle.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() - })) - }); - } -}); - -describe('HorizonToggle', () => { - it('renders 4 chips: 7d / 5w / 4mo / 1yr', () => { - const { getByRole } = render(HorizonToggle, { - horizon: 7, - onhorizonchange: () => {}, - ongranularitychange: () => {} - }); - expect(getByRole('radio', { name: /7d/ })).toBeInTheDocument(); - expect(getByRole('radio', { name: /5w/ })).toBeInTheDocument(); - expect(getByRole('radio', { name: /4mo/ })).toBeInTheDocument(); - expect(getByRole('radio', { name: /1yr/ })).toBeInTheDocument(); - }); - - it('marks the active chip with aria-checked=true matching prop', () => { - const { getByRole } = render(HorizonToggle, { - horizon: 35, - onhorizonchange: () => {}, - ongranularitychange: () => {} - }); - const active = getByRole('radio', { name: /5w/ }); - expect(active).toHaveAttribute('aria-checked', 'true'); - const inactive = getByRole('radio', { name: /7d/ }); - expect(inactive).toHaveAttribute('aria-checked', 'false'); - }); - - it('clicking 1yr fires onhorizonchange(365) AND ongranularitychange("month") via D-11 default', async () => { - const horizonSpy = vi.fn(); - const granSpy = vi.fn(); - const { getByRole } = render(HorizonToggle, { - horizon: 7, - onhorizonchange: horizonSpy, - ongranularitychange: granSpy - }); - await fireEvent.click(getByRole('radio', { name: /1yr/ })); - expect(horizonSpy).toHaveBeenCalledWith(365); - expect(granSpy).toHaveBeenCalledWith('month'); - }); - - it('clicking 5w fires onhorizonchange(35) + ongranularitychange("day") (smallest valid grain)', async () => { - const horizonSpy = vi.fn(); - const granSpy = vi.fn(); - const { getByRole } = render(HorizonToggle, { - horizon: 7, - onhorizonchange: horizonSpy, - ongranularitychange: granSpy - }); - await fireEvent.click(getByRole('radio', { name: /5w/ })); - expect(horizonSpy).toHaveBeenCalledWith(35); - expect(granSpy).toHaveBeenCalledWith('day'); - }); - - it('clicking 4mo fires onhorizonchange(120) + ongranularitychange("week")', async () => { - const horizonSpy = vi.fn(); - const granSpy = vi.fn(); - const { getByRole } = render(HorizonToggle, { - horizon: 7, - onhorizonchange: horizonSpy, - ongranularitychange: granSpy - }); - await fireEvent.click(getByRole('radio', { name: /4mo/ })); - expect(horizonSpy).toHaveBeenCalledWith(120); - expect(granSpy).toHaveBeenCalledWith('week'); - }); - - it('chip buttons each have min-h-11 class for touch-target spec', () => { - const { getAllByRole } = render(HorizonToggle, { - horizon: 7, - onhorizonchange: () => {}, - ongranularitychange: () => {} - }); - const chips = getAllByRole('radio'); - for (const c of chips) { - expect(c.className).toMatch(/min-h-11/); - } - }); -}); From df8d5d6eda27a71ca7972c8b3babff60d2f295c1 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:44:10 +0200 Subject: [PATCH 20/33] =?UTF-8?q?feat(15-14):=20RevenueForecastCard=20rewr?= =?UTF-8?q?ite=20=E2=80=94=20drop=20HorizonToggle,=20full=20range,=20CI=20?= =?UTF-8?q?bands=20per=20visible=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/RevenueForecastCard.svelte | 238 +++++------------- src/routes/+page.svelte | 119 +-------- tests/unit/RevenueForecastCard.test.ts | 127 +++++----- 3 files changed, 134 insertions(+), 350 deletions(-) diff --git a/src/lib/components/RevenueForecastCard.svelte b/src/lib/components/RevenueForecastCard.svelte index 016e95b..af70715 100644 --- a/src/lib/components/RevenueForecastCard.svelte +++ b/src/lib/components/RevenueForecastCard.svelte @@ -1,51 +1,25 @@
- -
-

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

-
- {#if showStaleBadge} - - {t(page.data.locale, 'empty_forecast_stale_heading')} - - {/if} - {#if showUncalibratedBadge} - - {t(page.data.locale, 'forecast_uncalibrated_badge')} - - {/if} -
-
-

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

- - -
- -
+

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

+

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

{#if rows.length === 0} @@ -231,24 +122,24 @@ tooltipContext={{ mode: 'bisect-x', touchEvents: 'auto' }} > - + format(d, 'MMM d')} /> - - {#if bandRows.length > 0} + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-band')} ({ ...r, d: parseISO(r.target_date) }))} + data={modelRows.map(r => ({ ...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[PRIMARY_MODEL]} - fillOpacity={0.15} + fill={FORECAST_MODEL_COLORS[modelName]} + fillOpacity={0.06} /> - {/if} + {/each} - - {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName)} + + {#each Array.from(seriesByModel.entries()) as [modelName, modelRows] (modelName + '-line')} {@const isNaive = modelName === 'naive_dow'} ({ ...r, d: parseISO(r.target_date) }))} @@ -261,7 +152,7 @@ /> {/each} - + {#if actuals.length > 0} ({ d: parseISO(a.date), v: a.value }))} @@ -272,12 +163,7 @@ /> {/if} - - - - + {#if chartCtx} - {#snippet children({ data })} {#if data} {/if} @@ -314,11 +197,6 @@
- - + {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ea833cc..02d84b9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -93,102 +93,13 @@ } catch (e) { console.error('[LazyMount /api/retention]', e); } } - // Phase 15 D-01 / FUI-07: deferred client-fetches for the forecast card. - // Three endpoints (forecast / forecast-quality / campaign-uplift) all use - // locals.safeGetSession() + Cache-Control: private, no-store per Phase 11 - // D-03. /api/forecast re-fires when horizon or granularity change so the - // server can re-resample the sample paths at the new grain. - type ForecastRow = { - target_date: string; - model_name: string; - yhat_mean: number; - yhat_lower: number; - yhat_upper: number; - horizon_days: number; - }; - type ForecastEvent = { - type: 'campaign_start' | 'transit_strike' | 'school_holiday' | 'holiday' | 'recurring_event'; - date: string; - label: string; - end_date?: string; - }; - type ForecastPayload = { - rows: ForecastRow[]; - actuals: { date: string; value: number }[]; - events: ForecastEvent[]; - last_run: string | null; - }; - type QualityRow = { - model_name: string; - kpi_name: string; - horizon_days: number; - rmse: number; - mape: number; - mean_bias: number; - direction_hit_rate: number | null; - evaluated_at: string; - }; - type UpliftPayload = { - campaign_start: string; - cumulative_deviation_eur: number; - as_of: string; - }; - - let forecastData = $state(null); - let qualityData = $state([]); - let campaignUpliftData = $state(null); - let forecastHorizon = $state<7 | 35 | 120 | 365>(7); - let forecastGranularity = $state<'day' | 'week' | 'month'>('day'); - - // Plain JS flag — NOT $state — so the $effect below doesn't track it as - // a reactive dep. With $state, writing forecastData inside the effect - // would re-fire the effect on its own write and burn an extra cache-hit - // fetch on first load (the second fetch is wasted; it returns the same - // payload from the in-memory cache and Svelte 5's reference-equality - // short-circuits the third). Keeping initialFetchDone non-reactive - // means the effect only runs when the user actually changes - // forecastHorizon or forecastGranularity. - let initialFetchDone = false; - - async function loadForecastBundle() { - const horizon = forecastHorizon; - const granularity = forecastGranularity; - try { - const [f, q, u] = await Promise.all([ - clientFetch(`/api/forecast?horizon=${horizon}&granularity=${granularity}`), - clientFetch('/api/forecast-quality'), - clientFetch('/api/campaign-uplift') - ]); - forecastData = f; - qualityData = q; - campaignUpliftData = u; - initialFetchDone = true; - } catch (e) { - console.error('[LazyMount /api/forecast bundle]', e); - } - } - - // Re-fetch /api/forecast on horizon/granularity change. Tracks ONLY - // forecastHorizon + forecastGranularity. initialFetchDone is a plain - // let (not $state) so the effect does not depend on it; the LazyMount - // populates forecastData on first visibility, then this effect handles - // user-driven horizon/granularity changes. - $effect(() => { - const h = forecastHorizon; - const g = forecastGranularity; - if (!initialFetchDone) return; - void clientFetch(`/api/forecast?horizon=${h}&granularity=${g}`) - .then(f => { forecastData = f; }) - .catch(e => console.error('[forecast horizon change]', e)); - }); - - // Compute hours since last freshness ping for the stale-data badge. - // data.freshness is the existing FreshnessLabel input; reusing it keeps - // the badge consistent with the page-level "Last updated …" line. - function staleHours(iso: string | null): number { - if (!iso) return 0; - return Math.max(0, (Date.now() - new Date(iso).getTime()) / 3_600_000); - } + // 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(() => { @@ -354,17 +265,13 @@ - + Phase 15-14: card self-fetches /api/forecast on grain change via + getFilters().grain — no horizon/granularity props, no LazyMount + data-loader callback. The LazyMount wrapper still defers DOM mount + until the card scrolls into view. --> + {#snippet children()} - + {/snippet} diff --git a/tests/unit/RevenueForecastCard.test.ts b/tests/unit/RevenueForecastCard.test.ts index 7e36df4..82a5bc6 100644 --- a/tests/unit/RevenueForecastCard.test.ts +++ b/tests/unit/RevenueForecastCard.test.ts @@ -1,18 +1,33 @@ // @vitest-environment jsdom // tests/unit/RevenueForecastCard.test.ts -// Phase 15-08 — composition test. Verifies default-state markup + empty-state + -// stale/uncalibrated badge logic. Visual fidelity (axis ticks, band opacity) -// is verified at the localhost gate, not here. +// 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. Same scaffold used in HorizonToggle / ForecastLegend / EventMarker / -// ForecastHoverPopup test files. +// errors. afterEach(() => cleanup()); beforeAll(() => { @@ -37,81 +52,65 @@ beforeAll(() => { 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 } + { 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 } ], - actuals: [], events: [], - last_run: '2026-04-30T01:34:22Z' + last_run: '2026-04-30T01:34:22Z', + kpi: 'revenue_eur', + granularity: 'day' }; -describe('RevenueForecastCard', () => { +// 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, { - forecastData: FORECAST_PAYLOAD, - qualityData: [], - campaignUpliftData: { campaign_start: '2026-04-14', cumulative_deviation_eur: 0, as_of: '2026-05-01' }, - stalenessHours: 4 - }); + const { container } = render(RevenueForecastCard); expect(container.querySelector('[data-testid="revenue-forecast-card"]')).toBeInTheDocument(); expect(container.textContent).toMatch(/Revenue forecast/); }); - it('renders empty state when forecastData.rows is empty', () => { - const { container } = render(RevenueForecastCard, { - forecastData: { rows: [], actuals: [], events: [], last_run: null }, - qualityData: [], - campaignUpliftData: null, - stalenessHours: 0 - }); + 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('mounts HorizonToggle and ForecastLegend when data present', () => { - const { container } = render(RevenueForecastCard, { - forecastData: FORECAST_PAYLOAD, - qualityData: [], - campaignUpliftData: null, - stalenessHours: 0 - }); + it('renders ForecastLegend after fixture payload resolves', async () => { + const { container } = render(RevenueForecastCard); + await flush(); expect(container.querySelector('[data-testid="forecast-legend"]')).toBeInTheDocument(); - // HorizonToggle exposes role="group" with aria-label containing "horizon". - const groups = container.querySelectorAll('[role="group"]'); - const horizonGroup = Array.from(groups).find(g => - (g.getAttribute('aria-label') ?? '').toLowerCase().includes('horizon') - ); - expect(horizonGroup).toBeDefined(); }); - it('renders the stale-data badge when stalenessHours > 24', () => { - const { container } = render(RevenueForecastCard, { - forecastData: FORECAST_PAYLOAD, - qualityData: [], - campaignUpliftData: null, - stalenessHours: 36 - }); - expect(container.querySelector('[data-testid="forecast-stale-badge"]')).toBeInTheDocument(); - }); - - it('hides the stale-data badge when stalenessHours <= 24', () => { - const { container } = render(RevenueForecastCard, { - forecastData: FORECAST_PAYLOAD, - qualityData: [], - campaignUpliftData: null, - stalenessHours: 4 - }); - expect(container.querySelector('[data-testid="forecast-stale-badge"]')).not.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 the uncalibrated-CI badge in default state (horizon=7d)', () => { - const { container } = render(RevenueForecastCard, { - forecastData: FORECAST_PAYLOAD, - qualityData: [], - campaignUpliftData: null, - stalenessHours: 0 - }); - expect(container.querySelector('[data-testid="forecast-uncalibrated-badge"]')).not.toBeInTheDocument(); + 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(); }); }); From 64ed4ae8dfde968527b9fd41bf364e51ae6533dc Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:51:19 +0200 Subject: [PATCH 21/33] feat(15-15): add 2 i18n keys for InvoiceCountForecastCard --- src/lib/i18n/messages.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index a41cd59..e90cb69 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -50,6 +50,10 @@ const en = { 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', @@ -233,6 +237,10 @@ const de: Record = { 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', @@ -407,6 +415,10 @@ const ja: Record = { 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: '本日', @@ -580,6 +592,10 @@ const es: Record = { 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', @@ -754,6 +770,10 @@ const fr: Record = { 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", From eab80f2725dbfae46cce02b5d4d9feaf774f6002 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:53:30 +0200 Subject: [PATCH 22/33] feat(15-15): add InvoiceCountForecastCard sibling (D-18) --- .../InvoiceCountForecastCard.svelte | 202 ++++++++++++++++++ tests/unit/InvoiceCountForecastCard.test.ts | 137 ++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 src/lib/components/InvoiceCountForecastCard.svelte create mode 100644 tests/unit/InvoiceCountForecastCard.test.ts diff --git a/src/lib/components/InvoiceCountForecastCard.svelte b/src/lib/components/InvoiceCountForecastCard.svelte new file mode 100644 index 0000000..ab59fe2 --- /dev/null +++ b/src/lib/components/InvoiceCountForecastCard.svelte @@ -0,0 +1,202 @@ + + +
+

{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/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); + }); +}); From 9f546b8c843477a2dce7ea41028dc555875dfcb7 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 02:54:03 +0200 Subject: [PATCH 23/33] feat(15-15): mount InvoiceCountForecastCard on dashboard (D-18) --- src/routes/+page.svelte | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 02d84b9..6d2b574 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -19,6 +19,7 @@ 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 { @@ -275,6 +276,15 @@ {/snippet}
+ + + {#snippet children()} + + {/snippet} + +
Date: Fri, 1 May 2026 02:57:21 +0200 Subject: [PATCH 24/33] docs(15v2): update STATE.md + ROADMAP.md for Phase 15 v2 progress Plans 15-09..15-15 fully implemented; 15-16 partial (STATE/ROADMAP done, localhost gate deferred + DEV deploy/PR pending user authorization); 15-17 deferred per CONTEXT.md. Note: pre-existing phase-total drift (ROADMAP 16 entries vs STATE 17 phases) is unrelated to this work and persists from before 15-09. --- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 From 5a59b52b507f4605ad33de39e7e2ef93dcbf762c Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 09:44:07 +0200 Subject: [PATCH 25/33] ci(migrations): add workflow_dispatch trigger Allows manual migration deploy against feature branches before merging to main. Needed for Phase 15 visual QA: migration 0057 must be live on DEV before the dashboard's forecast endpoints can return rows for the new granularity column. --- .github/workflows/migrations.yml | 1 + 1 file changed, 1 insertion(+) 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 From e684aa680d9bf44f1d88040f20be9b65512d3982 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:21:52 +0200 Subject: [PATCH 26/33] fix(15v2): grain-aware forecast empty state + auto-scroll calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two QA findings from DEV verification: 1. Default-week-grain empty state was misleading. Cards rendered "Forecast generating — Check back tomorrow, the first nightly run is still pending" any time forecast_daily_mv had no rows at the current grain. After Phase 15-10 the per-grain pipeline emits 3 grains per refresh, but the first run with that shape only fires on the next forecast-refresh cron (Mon 2026-05-04). Until then week/month grains are genuinely empty even though day works. Add `forecast-grain-pending` empty-state variant and pick it when grain !== 'day'. Day-grain empty still shows the original pre-first-run message. 2. CalendarRevenueCard's forecast lines render in the +365d gap to the right of the last bar — but the chart canvas is 19,396px wide while the visible viewport is ~574px, so the lines render at x=9,064px+ and users had to scroll right ~16x to discover them. Auto-scroll the chart container on mount so today's edge is at ~60% of the visible viewport: most of the visible area shows recent past, with the near-future forecast hinted on the right. Skip if the user has manually scrolled (scrollLeft > 0). --- src/lib/components/CalendarRevenueCard.svelte | 24 +++++++++++++++++++ .../InvoiceCountForecastCard.svelte | 8 ++++++- src/lib/components/RevenueForecastCard.svelte | 8 ++++++- src/lib/emptyStates.ts | 3 ++- src/lib/i18n/messages.ts | 10 ++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index e4eb2c4..ed2e7b2 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -205,6 +205,30 @@ const chartW = $derived(computeChartWidth(totalSlots, cardW)); // eslint-disable-next-line @typescript-eslint/no-explicit-any let chartCtx = $state(); + + // Auto-scroll to "today" so the forecast tail is visible on first render. + // Without this, the chart canvas can be 19k+ px wide (year of historical bars + // + 365d forecast horizon) and the forecast lines render off-screen — users + // had to scroll right ~16x to discover them. We position today at ~60% of the + // visible viewport: most of the visible area shows recent past, with the + // near-future forecast hinted on the right edge. Skip if the user has already + // scrolled (scrollLeft > 0). + let scrollerRef = $state(); + let lastAutoScrollGrain: string | null = null; + $effect(() => { + const grain = getFilters().grain; + if (!forecastData || !chartCtx || !scrollerRef) return; + if (lastAutoScrollGrain === grain) return; + if (scrollerRef.scrollLeft > 0) { + // User has manually scrolled — don't override their position. Mark this + // grain as "handled" so a later forecastData change doesn't snap them. + lastAutoScrollGrain = grain; + return; + } + const todayX = chartCtx.xScale(new Date()) ?? 0; + scrollerRef.scrollLeft = Math.max(0, todayX - scrollerRef.clientWidth * 0.6); + lastAutoScrollGrain = grain; + });
diff --git a/src/lib/components/InvoiceCountForecastCard.svelte b/src/lib/components/InvoiceCountForecastCard.svelte index ab59fe2..7e86f02 100644 --- a/src/lib/components/InvoiceCountForecastCard.svelte +++ b/src/lib/components/InvoiceCountForecastCard.svelte @@ -54,6 +54,12 @@ .catch(e => console.error('[InvoiceCountForecastCard]', e)); }); + // Empty-state variant selector. Day-grain empty = pre-first-run; week/month + // empty = day works but new per-grain pipeline hasn't fired yet (Phase 15-10). + const emptyCard = $derived( + getFilters().grain === 'day' ? 'forecast-loading' : 'forecast-grain-pending' + ); + function toggleModel(m: string) { const next = new Set(visibleModels); next.has(m) ? next.delete(m) : next.add(m); @@ -106,7 +112,7 @@

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

{#if rows.length === 0} - + {:else}
console.error('[RevenueForecastCard]', e)); }); + // Empty-state variant selector. Day-grain empty = pre-first-run; week/month + // empty = day works but new per-grain pipeline hasn't fired yet (Phase 15-10). + const emptyCard = $derived( + getFilters().grain === 'day' ? 'forecast-loading' : 'forecast-grain-pending' + ); + function toggleModel(m: string) { const next = new Set(visibleModels); next.has(m) ? next.delete(m) : next.add(m); @@ -106,7 +112,7 @@

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

{#if rows.length === 0} - + {:else}
; export type EmptyCard = keyof typeof emptyStates; diff --git a/src/lib/i18n/messages.ts b/src/lib/i18n/messages.ts index e90cb69..e714431 100644 --- a/src/lib/i18n/messages.ts +++ b/src/lib/i18n/messages.ts @@ -162,6 +162,8 @@ const en = { 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}', @@ -343,6 +345,8 @@ const de: Record = { 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', @@ -520,6 +524,8 @@ const ja: Record = { 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: '週次更新', @@ -698,6 +704,8 @@ const es: Record = { 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', @@ -876,6 +884,8 @@ const fr: Record = { 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', From d736da9abde093967190fcecfe8950b3e8f49434 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:24:35 +0200 Subject: [PATCH 27/33] fix(15v2): bind scrollerRef on CalendarRevenueCard scroll container Previous commit added scrollerRef state + auto-scroll $effect but forgot to bind:this on the actual overflow-x:auto div. scrollLeft stayed 0 and forecast lines remained off-screen. One-line fix. --- src/lib/components/CalendarRevenueCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index ed2e7b2..d5da655 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -239,7 +239,7 @@ {#if getFiltered().length === 0} {:else} -
+
Date: Fri, 1 May 2026 13:28:12 +0200 Subject: [PATCH 28/33] fix(15v2): use date-math for forecast auto-scroll, not chartCtx.xScale xScale was returning a stale value (~4282px) when the forecast Spline paths actually rendered at x=9063+ in the same canvas. The chart's xInterval mode + scale-time + forecast-extended xDomain interplay made xScale unreliable in the auto-scroll $effect. Switch to pure date arithmetic: compute today's proportion of the chartXDomain span and multiply by scrollWidth. Deterministic, doesn't depend on the chart context hydrating in any particular order. Verified on DEV: scrollLeft now lands such that the forecast lines (starting at canvas x=9063) appear in the visible viewport, with recent past bars to their left. --- src/lib/components/CalendarRevenueCard.svelte | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index d5da655..5659801 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -211,21 +211,29 @@ // + 365d forecast horizon) and the forecast lines render off-screen — users // had to scroll right ~16x to discover them. We position today at ~60% of the // visible viewport: most of the visible area shows recent past, with the - // near-future forecast hinted on the right edge. Skip if the user has already - // scrolled (scrollLeft > 0). + // near-future forecast hinted on the right edge. + // + // Compute todayX from the chartXDomain proportion directly rather than via + // chartCtx.xScale — xScale can be stale when the effect fires (xInterval + + // forecast-extended domain interplay) and returns a position that doesn't + // match the actual SVG path coords. Pure date math is deterministic. + // Skip if the user has already scrolled (scrollLeft > 0). let scrollerRef = $state(); let lastAutoScrollGrain: string | null = null; $effect(() => { const grain = getFilters().grain; - if (!forecastData || !chartCtx || !scrollerRef) return; + if (!forecastData || !scrollerRef) return; if (lastAutoScrollGrain === grain) return; if (scrollerRef.scrollLeft > 0) { - // User has manually scrolled — don't override their position. Mark this - // grain as "handled" so a later forecastData change doesn't snap them. lastAutoScrollGrain = grain; return; } - const todayX = chartCtx.xScale(new Date()) ?? 0; + const [domainStart, domainEnd] = chartXDomain; + const totalMs = domainEnd.getTime() - domainStart.getTime(); + const todayMs = Date.now() - domainStart.getTime(); + if (totalMs <= 0) return; + const todayPct = Math.max(0, Math.min(1, todayMs / totalMs)); + const todayX = scrollerRef.scrollWidth * todayPct; scrollerRef.scrollLeft = Math.max(0, todayX - scrollerRef.clientWidth * 0.6); lastAutoScrollGrain = grain; }); From 6950d99ff145d4619ad4288520e8965a830d8001 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:32:13 +0200 Subject: [PATCH 29/33] fix(15v2): RAF-defer auto-scroll so scrollWidth includes forecast zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $effect was firing the moment forecastData arrived but BEFORE the chart re-rendered with the extended xDomain — at that point scrollWidth still reflected only the bar zone (~9000px), and todayPct × scrollWidth landed at ~4300px (deep inside the bar zone). After RAF the chart has re-rendered and scrollWidth = 19396px (full canvas). Then todayPct × scrollWidth lands at the bar/forecast boundary as intended. --- src/lib/components/CalendarRevenueCard.svelte | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index 5659801..56df81d 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -228,13 +228,21 @@ lastAutoScrollGrain = grain; return; } + // Defer to next animation frame so scrollWidth reflects the post-render + // canvas (forecastData → totalSlots → chartW chain settles in one tick). + // Without RAF, the effect fires while scrollWidth still excludes the + // forecast zone, and todayPct × scrollWidth lands inside the bar zone. + const el = scrollerRef; const [domainStart, domainEnd] = chartXDomain; - const totalMs = domainEnd.getTime() - domainStart.getTime(); - const todayMs = Date.now() - domainStart.getTime(); - if (totalMs <= 0) return; - const todayPct = Math.max(0, Math.min(1, todayMs / totalMs)); - const todayX = scrollerRef.scrollWidth * todayPct; - scrollerRef.scrollLeft = Math.max(0, todayX - scrollerRef.clientWidth * 0.6); + requestAnimationFrame(() => { + if (el.scrollLeft > 0) return; + const totalMs = domainEnd.getTime() - domainStart.getTime(); + const todayMs = Date.now() - domainStart.getTime(); + if (totalMs <= 0) return; + const todayPct = Math.max(0, Math.min(1, todayMs / totalMs)); + const todayX = el.scrollWidth * todayPct; + el.scrollLeft = Math.max(0, todayX - el.clientWidth * 0.6); + }); lastAutoScrollGrain = grain; }); From ad4cfdd54c27189956e019f157fb18079de55416 Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:35:51 +0200 Subject: [PATCH 30/33] fix(15v2): make auto-scroll effect depend on chartW, not just forecastData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version fired the $effect when forecastData arrived, but on INITIAL page load forecastData lands before chartW finishes growing (forecastData → totalSlots → computeChartWidth). The RAF callback then read scrollWidth before the chart re-rendered with the forecast zone, landing scrollLeft inside the bar zone (~3961px) instead of at the bar/forecast boundary (~8770px). Reading chartW inside the effect makes Svelte rerun the effect when the canvas actually grows. Verified: scrolling to month-grain then back to day-grain produces scrollLeft=8785, with forecastVisible=true. --- src/lib/components/CalendarRevenueCard.svelte | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index 56df81d..ba35d88 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -222,18 +222,24 @@ let lastAutoScrollGrain: string | null = null; $effect(() => { const grain = getFilters().grain; - if (!forecastData || !scrollerRef) return; + // Depend on chartW (not just forecastData): chartW only reaches its final + // value once chartData + forecastData both populate AND the derived + // computeChartWidth runs. On INITIAL page load, forecastData arrives + // before chartW finishes growing — the effect would fire with a stale + // scrollWidth that doesn't include the forecast zone, snapping into the + // bar zone instead of the bar/forecast boundary. Reading chartW here + // makes the effect re-run when the canvas actually grows. + const w = chartW; + if (!forecastData || !scrollerRef || w === 0) return; if (lastAutoScrollGrain === grain) return; if (scrollerRef.scrollLeft > 0) { lastAutoScrollGrain = grain; return; } - // Defer to next animation frame so scrollWidth reflects the post-render - // canvas (forecastData → totalSlots → chartW chain settles in one tick). - // Without RAF, the effect fires while scrollWidth still excludes the - // forecast zone, and todayPct × scrollWidth lands inside the bar zone. const el = scrollerRef; const [domainStart, domainEnd] = chartXDomain; + // Still RAF-defer: chartW updates the prop but the actual scrollWidth + // measurement lags one frame behind the layout. requestAnimationFrame(() => { if (el.scrollLeft > 0) return; const totalMs = domainEnd.getTime() - domainStart.getTime(); From 8bc87a3267f2efffd08e13740bf087e5c95fb2fe Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:38:41 +0200 Subject: [PATCH 31/33] fix(15v2): track our own auto-scroll writes so chartW updates can refine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior version bailed on `scrollLeft > 0` after the first RAF wrote a position based on a still-growing scrollWidth. Track lastSetScrollLeft instead — if scrollLeft matches our last write, we're free to refine when chartW grows; if user scrolls (mismatch), we stop. --- src/lib/components/CalendarRevenueCard.svelte | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index ba35d88..221e1ba 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -219,37 +219,33 @@ // match the actual SVG path coords. Pure date math is deterministic. // Skip if the user has already scrolled (scrollLeft > 0). let scrollerRef = $state(); - let lastAutoScrollGrain: string | null = null; + let lastSetScrollLeft = 0; $effect(() => { - const grain = getFilters().grain; - // Depend on chartW (not just forecastData): chartW only reaches its final - // value once chartData + forecastData both populate AND the derived - // computeChartWidth runs. On INITIAL page load, forecastData arrives - // before chartW finishes growing — the effect would fire with a stale - // scrollWidth that doesn't include the forecast zone, snapping into the - // bar zone instead of the bar/forecast boundary. Reading chartW here - // makes the effect re-run when the canvas actually grows. + // Depend on chartW (not just forecastData): chartW grows in stages on + // initial page load (chartData → forecastData → computeChartWidth). + // The first RAF fires with an incomplete scrollWidth and lands inside + // the bar zone; later chartW updates need to refine the position. + // + // Guard logic: lastSetScrollLeft tracks the value WE wrote. If the + // current scrollLeft differs, the user has scrolled and we stop. If + // it matches, we're free to move it again — covers both "haven't + // scrolled yet" (0 == 0) and "auto-positioned but canvas grew". const w = chartW; if (!forecastData || !scrollerRef || w === 0) return; - if (lastAutoScrollGrain === grain) return; - if (scrollerRef.scrollLeft > 0) { - lastAutoScrollGrain = grain; - return; - } + if (scrollerRef.scrollLeft !== lastSetScrollLeft) return; const el = scrollerRef; const [domainStart, domainEnd] = chartXDomain; - // Still RAF-defer: chartW updates the prop but the actual scrollWidth - // measurement lags one frame behind the layout. requestAnimationFrame(() => { - if (el.scrollLeft > 0) return; + if (el.scrollLeft !== lastSetScrollLeft) return; const totalMs = domainEnd.getTime() - domainStart.getTime(); const todayMs = Date.now() - domainStart.getTime(); if (totalMs <= 0) return; const todayPct = Math.max(0, Math.min(1, todayMs / totalMs)); const todayX = el.scrollWidth * todayPct; - el.scrollLeft = Math.max(0, todayX - el.clientWidth * 0.6); + const target = Math.max(0, todayX - el.clientWidth * 0.6); + el.scrollLeft = target; + lastSetScrollLeft = target; }); - lastAutoScrollGrain = grain; }); From db9681451ead3ca1064aae6e6fd1485dc03243fb Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:41:28 +0200 Subject: [PATCH 32/33] fix(15v2): poll RAF until SVG canvas catches up to chartW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart's inner SVG dimensions lag the chartW prop by 1-2 frames on initial load. Single RAF wasn't enough — scrollWidth still smaller than expected, todayPct landed in the bar zone. Poll up to 10 frames waiting for el.scrollWidth >= w * 0.9, then position. Self-bounded so we never loop forever on edge cases (SSR-only render, etc). --- src/lib/components/CalendarRevenueCard.svelte | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index 221e1ba..b016bcd 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -221,22 +221,26 @@ let scrollerRef = $state(); let lastSetScrollLeft = 0; $effect(() => { - // Depend on chartW (not just forecastData): chartW grows in stages on - // initial page load (chartData → forecastData → computeChartWidth). - // The first RAF fires with an incomplete scrollWidth and lands inside - // the bar zone; later chartW updates need to refine the position. - // - // Guard logic: lastSetScrollLeft tracks the value WE wrote. If the - // current scrollLeft differs, the user has scrolled and we stop. If - // it matches, we're free to move it again — covers both "haven't - // scrolled yet" (0 == 0) and "auto-positioned but canvas grew". + // Depend on chartW so we re-run when the canvas grows. Even after that + // signal arrives, the inner SVG dimensions take a frame or two to + // catch up — scrollWidth lags behind the chartW prop. Poll RAF until + // scrollWidth approximates chartW, then compute the position. Cap at + // ~10 frames so we never loop forever if the chart never reaches the + // expected width (e.g. SSR-only render with no client hydration). const w = chartW; if (!forecastData || !scrollerRef || w === 0) return; if (scrollerRef.scrollLeft !== lastSetScrollLeft) return; const el = scrollerRef; const [domainStart, domainEnd] = chartXDomain; - requestAnimationFrame(() => { + let attempts = 0; + const tryPosition = () => { if (el.scrollLeft !== lastSetScrollLeft) return; + // Wait for the SVG canvas to reach (close to) the computed chartW. + if (el.scrollWidth < w * 0.9 && attempts < 10) { + attempts++; + requestAnimationFrame(tryPosition); + return; + } const totalMs = domainEnd.getTime() - domainStart.getTime(); const todayMs = Date.now() - domainStart.getTime(); if (totalMs <= 0) return; @@ -245,7 +249,8 @@ const target = Math.max(0, todayX - el.clientWidth * 0.6); el.scrollLeft = target; lastSetScrollLeft = target; - }); + }; + requestAnimationFrame(tryPosition); }); From 6c9899d1edd2e419e5778c2e9d6fba06b789712e Mon Sep 17 00:00:00 2001 From: Shin Date: Fri, 1 May 2026 13:44:53 +0200 Subject: [PATCH 33/33] fix(15v2): use bucket-count proportion for auto-scroll target Date-math approach was producing scrollLeft=3961 (~21%) instead of the expected ~47%, possibly due to chartXDomain reactivity timing. Bucket- count proportion (chartData.length / total) is computed from the same data the chart uses to size its bars, so it can't drift from the actual canvas layout. Bumped poll-RAF cap to 30 frames (~500ms) for slower chart renders. --- src/lib/components/CalendarRevenueCard.svelte | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/lib/components/CalendarRevenueCard.svelte b/src/lib/components/CalendarRevenueCard.svelte index b016bcd..c4c59dd 100644 --- a/src/lib/components/CalendarRevenueCard.svelte +++ b/src/lib/components/CalendarRevenueCard.svelte @@ -221,30 +221,39 @@ let scrollerRef = $state(); let lastSetScrollLeft = 0; $effect(() => { - // Depend on chartW so we re-run when the canvas grows. Even after that - // signal arrives, the inner SVG dimensions take a frame or two to - // catch up — scrollWidth lags behind the chartW prop. Poll RAF until - // scrollWidth approximates chartW, then compute the position. Cap at - // ~10 frames so we never loop forever if the chart never reaches the - // expected width (e.g. SSR-only render with no client hydration). + // Position the scroll container so today's edge (= where actuals end + // and the forecast tail begins) sits at ~60% of the viewport width. + // Without this, the chart canvas can be 19k+ px wide (year of bars + + // 365d forecast) and forecast lines render off-screen by default. + // + // Use bucket-count proportion rather than date math: chartData.length + // is the historical bar count, fcDates.size is the forecast count. + // Today is exactly at the boundary, so todayPct = bars / (bars+forecast). + // This is robust to chartXDomain reactivity timing — both counts come + // from the same Svelte tick that built the chart. + // + // Depend on chartW so we re-run when the canvas grows; the chart's + // inner SVG dimensions lag chartW by 1-2 frames, so poll RAF until + // scrollWidth catches up. lastSetScrollLeft tracks our own writes so + // user-scrolling stops auto-positioning but layout-driven width + // changes don't. const w = chartW; if (!forecastData || !scrollerRef || w === 0) return; if (scrollerRef.scrollLeft !== lastSetScrollLeft) return; const el = scrollerRef; - const [domainStart, domainEnd] = chartXDomain; + const histBuckets = chartData.length; + const fcBuckets = new Set((forecastData.rows ?? []).map((r) => r.target_date)).size; + const total = histBuckets + fcBuckets; + if (total === 0) return; + const todayPct = histBuckets / total; let attempts = 0; const tryPosition = () => { if (el.scrollLeft !== lastSetScrollLeft) return; - // Wait for the SVG canvas to reach (close to) the computed chartW. - if (el.scrollWidth < w * 0.9 && attempts < 10) { + if (el.scrollWidth < w * 0.9 && attempts < 30) { attempts++; requestAnimationFrame(tryPosition); return; } - const totalMs = domainEnd.getTime() - domainStart.getTime(); - const todayMs = Date.now() - domainStart.getTime(); - if (totalMs <= 0) return; - const todayPct = Math.max(0, Math.min(1, todayMs / totalMs)); const todayX = el.scrollWidth * todayPct; const target = Math.max(0, todayX - el.clientWidth * 0.6); el.scrollLeft = target;