@@ -35,6 +35,7 @@ import {
3535import { downloadCsv , toCsv } from '@/lib/csv-export'
3636import { formatCurrency , formatNumber , getErrorMessage } from '@/lib/api'
3737import {
38+ assumptionDateErrors ,
3839 buildMultiSeries ,
3940 coverageLabel ,
4041 coverageVariant ,
@@ -73,8 +74,11 @@ export default function WhatIfPlannerPage() {
7374 const [ selectedJobId , setSelectedJobId ] = useState ( '' )
7475 const [ horizon , setHorizon ] = useState ( 14 )
7576 const { data : job } = useJob ( selectedJobId , ! ! selectedJobId )
76- // A predict job's params.run_id is the baseline model artifact key.
77- const baselineRunId = typeof job ?. params ?. run_id === 'string' ? job . params . run_id : null
77+ // A completed `train` job stores result.run_id — the model-artifact key
78+ // POST /scenarios/simulate resolves. (This is NOT a registry run id.)
79+ // A `regression` baseline routes the simulate call down the model_exogenous
80+ // re-forecast branch; other model types fall back to the heuristic factor.
81+ const baselineRunId = typeof job ?. result ?. run_id === 'string' ? job . result . run_id : null
7882
7983 // -- Assumption form state ---------------------------------------------
8084 const [ priceEnabled , setPriceEnabled ] = useState ( false )
@@ -97,6 +101,19 @@ export default function WhatIfPlannerPage() {
97101 const [ lifecycleStage , setLifecycleStage ] =
98102 useState < ( typeof LIFECYCLE_STAGES ) [ number ] > ( 'maturity' )
99103
104+ // -- Derived validation ------------------------------------------------
105+ // Enabling Price/Promotion without filling both dates would submit empty
106+ // strings — Pydantic date validation rejects those with an RFC 7807 422.
107+ // Gate Run/Save on this so the form can never produce that request (#228).
108+ const dateErrors = assumptionDateErrors ( {
109+ priceEnabled,
110+ priceStart,
111+ priceEnd,
112+ promoEnabled,
113+ promoStart,
114+ promoEnd,
115+ } )
116+
100117 // -- Results / persistence state ---------------------------------------
101118 const [ simulated , setSimulated ] = useState < ScenarioComparison | null > ( null )
102119 const [ planName , setPlanName ] = useState ( '' )
@@ -152,7 +169,7 @@ export default function WhatIfPlannerPage() {
152169 }
153170
154171 async function handleRun ( ) {
155- if ( ! baselineRunId ) return
172+ if ( ! baselineRunId || dateErrors . hasErrors ) return
156173 setRunError ( null )
157174 setReloadId ( '' )
158175 try {
@@ -169,7 +186,7 @@ export default function WhatIfPlannerPage() {
169186 }
170187
171188 async function handleSave ( ) {
172- if ( ! baselineRunId || ! planName . trim ( ) ) return
189+ if ( ! baselineRunId || ! planName . trim ( ) || dateErrors . hasErrors ) return
173190 setRunError ( null )
174191 try {
175192 await createScenario . mutateAsync ( {
@@ -245,12 +262,15 @@ export default function WhatIfPlannerPage() {
245262 < CardHeader >
246263 < CardTitle > 1. Pick a baseline</ CardTitle >
247264 < CardDescription >
248- Choose a completed prediction job — its model is the baseline this scenario adjusts.
265+ Choose a completed training job — its model is the baseline this scenario
266+ adjusts. A regression baseline is genuinely re-forecast through the model
267+ (model-driven); naive, seasonal-naive and moving-average baselines use a
268+ heuristic adjustment factor.
249269 </ CardDescription >
250270 </ CardHeader >
251271 < CardContent className = "space-y-4" >
252272 < JobPicker
253- jobType = "predict "
273+ jobType = "train "
254274 selectedJobId = { selectedJobId }
255275 onSelect = { setSelectedJobId }
256276 autoSelectLatest
@@ -274,7 +294,7 @@ export default function WhatIfPlannerPage() {
274294 </ div >
275295 { selectedJobId && ! baselineRunId && (
276296 < p className = "text-sm text-muted-foreground" >
277- The selected job has no model artifact — pick a completed predict job.
297+ The selected job has no model artifact — pick a completed train job.
278298 </ p >
279299 ) }
280300 </ CardContent >
@@ -317,6 +337,9 @@ export default function WhatIfPlannerPage() {
317337 value = { priceStart }
318338 onChange = { ( event ) => setPriceStart ( event . target . value ) }
319339 />
340+ { dateErrors . priceStart && (
341+ < p className = "text-xs text-destructive" > Required</ p >
342+ ) }
320343 </ div >
321344 < div className = "space-y-1" >
322345 < span className = "text-xs text-muted-foreground" > To</ span >
@@ -326,6 +349,9 @@ export default function WhatIfPlannerPage() {
326349 value = { priceEnd }
327350 onChange = { ( event ) => setPriceEnd ( event . target . value ) }
328351 />
352+ { dateErrors . priceEnd && (
353+ < p className = "text-xs text-destructive" > Required</ p >
354+ ) }
329355 </ div >
330356 </ div >
331357 ) }
@@ -368,6 +394,9 @@ export default function WhatIfPlannerPage() {
368394 value = { promoStart }
369395 onChange = { ( event ) => setPromoStart ( event . target . value ) }
370396 />
397+ { dateErrors . promoStart && (
398+ < p className = "text-xs text-destructive" > Required</ p >
399+ ) }
371400 </ div >
372401 < div className = "space-y-1" >
373402 < span className = "text-xs text-muted-foreground" > To</ span >
@@ -377,6 +406,9 @@ export default function WhatIfPlannerPage() {
377406 value = { promoEnd }
378407 onChange = { ( event ) => setPromoEnd ( event . target . value ) }
379408 />
409+ { dateErrors . promoEnd && (
410+ < p className = "text-xs text-destructive" > Required</ p >
411+ ) }
380412 </ div >
381413 </ div >
382414 ) }
@@ -462,7 +494,10 @@ export default function WhatIfPlannerPage() {
462494 </ div >
463495
464496 < div className = "flex flex-wrap items-center gap-3 border-t pt-4" >
465- < Button onClick = { handleRun } disabled = { ! baselineRunId || simulate . isPending } >
497+ < Button
498+ onClick = { handleRun }
499+ disabled = { ! baselineRunId || simulate . isPending || dateErrors . hasErrors }
500+ >
466501 { simulate . isPending ? (
467502 < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
468503 ) : (
@@ -593,7 +628,12 @@ export default function WhatIfPlannerPage() {
593628 </ div >
594629 < Button
595630 onClick = { handleSave }
596- disabled = { ! baselineRunId || ! planName . trim ( ) || createScenario . isPending }
631+ disabled = {
632+ ! baselineRunId ||
633+ ! planName . trim ( ) ||
634+ createScenario . isPending ||
635+ dateErrors . hasErrors
636+ }
597637 >
598638 { createScenario . isPending ? (
599639 < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
0 commit comments