fix: custom run is one of two modes — replay a morning, or analyse a period

The three-date form let 'data as of' and 'window to' overlap and resolved the
clash by a hidden rule. Now a two-card choice: Replay (one date; 6 weeks
before it vs the 8 before those) or Period (from–to vs the 8 weeks before
it). Each mode sends only its own fields; the preview names the exact periods.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-17 13:30:54 +05:30
parent 77cf90e40b
commit f86a87af38

View File

@ -21,14 +21,36 @@ import { shortDate } from '../format';
* the same thing; anchored before the August spike, a run can genuinely come * the same thing; anchored before the August spike, a run can genuinely come
* back "nothing to report" — which is what most mornings look like. * back "nothing to report" — which is what most mornings look like.
*/ */
function ModeCard({ active, onClick, title, body }: { active: boolean; onClick: () => void; title: string; body: string }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`rounded-lg border px-3.5 py-3 text-left transition-colors ${
active ? 'border-brand bg-brand/10 ring-1 ring-brand ring-inset' : 'border-line hover:border-brand/60'
}`}
>
<span className="block text-sm font-semibold text-ink">{title}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted">{body}</span>
</button>
);
}
export function RunReviewScreen() { export function RunReviewScreen() {
const navigate = useNavigate(); const navigate = useNavigate();
const { client } = useZino(); const { client } = useZino();
const [busy, setBusy] = useState<'now' | 'custom' | null>(null); const [busy, setBusy] = useState<'now' | 'custom' | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [asOf, setAsOf] = useState(''); // Two custom runs, never mixed. "Replay" = one date, the morning to stand on;
const [from, setFrom] = useState(''); // "Period" = from–to, the span to analyse. Each sends only its own fields.
const [to, setTo] = useState(''); const [mode, setMode] = useState<'replay' | 'period'>('replay');
const [asOfInput, setAsOf] = useState('');
const [fromInput, setFrom] = useState('');
const [toInput, setTo] = useState('');
const asOf = mode === 'replay' ? asOfInput : '';
const from = mode === 'period' ? fromInput : '';
const to = mode === 'period' ? toInput : '';
// The data's own span, so a window cannot be set where there is nothing to // The data's own span, so a window cannot be set where there is nothing to
// compare: a start before the first claim leaves no baseline, and a run on // compare: a start before the first claim leaves no baseline, and a run on
@ -47,9 +69,9 @@ export function RunReviewScreen() {
const baseTo = recentFrom ? addDays(recentFrom, -1) : ''; const baseTo = recentFrom ? addDays(recentFrom, -1) : '';
const dateErr = const dateErr =
from && effectiveTo && from > effectiveTo from && effectiveTo && from > effectiveTo
? 'The window must start before it ends.' ? 'The period must start before it ends.'
: to && asOf && to > asOf : mode === 'period' && (!from || !to)
? '"Window to" cannot be after "data as of".' ? null
: dataFrom && baseFrom && baseFrom < dataFrom : dataFrom && baseFrom && baseFrom < dataFrom
? `The 8-week baseline before ${shortDate(recentFrom)} needs data from ${shortDate(baseFrom)}, but the data begins ${shortDate(dataFrom)}. Start the window on or after ${shortDate(addDays(dataFrom, 56))}.` ? `The 8-week baseline before ${shortDate(recentFrom)} needs data from ${shortDate(baseFrom)}, but the data begins ${shortDate(dataFrom)}. Start the window on or after ${shortDate(addDays(dataFrom, 56))}.`
: null; : null;
@ -61,11 +83,10 @@ export function RunReviewScreen() {
// Every field on the activity is optional; send only the ones the // Every field on the activity is optional; send only the ones the
// reviewer set, so a plain run is a genuinely empty submission. // reviewer set, so a plain run is a genuinely empty submission.
const data: Record<string, string> = {}; const data: Record<string, string> = {};
if (custom) { if (custom && mode === 'replay') data.review_as_of__review = asOf;
// "Window to" is the ceiling; "data as of" only matters when no window end is given. if (custom && mode === 'period') {
if (asOf && !to) data.review_as_of__review = asOf; data.review_from__review = from;
if (from) data.review_from__review = from; data.review_to__review = to;
if (to) data.review_to__review = to;
} }
const res = await client.startInstance(WORKFLOW, ACT.REVIEW, data); const res = await client.startInstance(WORKFLOW, ACT.REVIEW, data);
if (res.instance_id) navigate(`/c/${res.instance_id}`); if (res.instance_id) navigate(`/c/${res.instance_id}`);
@ -137,31 +158,48 @@ export function RunReviewScreen() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold text-ink">Custom run</p> <p className="font-semibold text-ink">Custom run</p>
<p className="mt-0.5 text-sm text-muted"> <p className="mt-0.5 text-sm text-muted">
Replay a past morning, or point the review at a specific period. Leave a date blank to Choose one of the two.
use the default.
{dataFrom && dataTo ? ` Data available ${shortDate(dataFrom)} – ${shortDate(dataTo)}, ${dataTo.slice(0, 4)}.` : ''} {dataFrom && dataTo ? ` Data available ${shortDate(dataFrom)} – ${shortDate(dataTo)}, ${dataTo.slice(0, 4)}.` : ''}
</p> </p>
</div> </div>
</div> </div>
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
<div> <ModeCard
<Label>Data as of</Label> active={mode === 'replay'}
<Input type="date" value={asOf} onChange={setAsOf} /> onClick={() => setMode('replay')}
<p className="mt-1 text-[11px] text-faint">Ignore anything after this date. Try {shortDate(QUIET_MORNING_AS_OF)} for a quiet morning.</p> title="Replay a past morning"
</div> body="One date. The review runs as if it were that morning: the 6 weeks before it, compared with the 8 weeks before those. Anything after the date is ignored."
<div> />
<Label>Window from</Label> <ModeCard
<Input type="date" value={from} onChange={setFrom} /> active={mode === 'period'}
<p className="mt-1 text-[11px] text-faint">Start of the period Ava analyses.</p> onClick={() => setMode('period')}
</div> title="Analyse a specific period"
<div> body="A start and an end. That span is compared with the 8 weeks before it starts. Anything after the end is ignored."
<Label>Window to</Label> />
<Input type="date" value={to} onChange={setTo} />
<p className="mt-1 text-[11px] text-faint">End of the period. Defaults to "data as of".</p>
</div>
</div> </div>
{mode === 'replay' ? (
<div className="mt-4 sm:max-w-xs">
<Label>Review as of</Label>
<Input type="date" value={asOfInput} onChange={setAsOf} />
<p className="mt-1 text-[11px] text-faint">Try {shortDate(QUIET_MORNING_AS_OF)} for a morning with no findings.</p>
</div>
) : (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<Label>From</Label>
<Input type="date" value={fromInput} onChange={setFrom} />
<p className="mt-1 text-[11px] text-faint">First day of the period.</p>
</div>
<div>
<Label>To</Label>
<Input type="date" value={toInput} onChange={setTo} />
<p className="mt-1 text-[11px] text-faint">Last day. Up to 12 weeks after "From".</p>
</div>
</div>
)}
<p className="mt-3 text-xs text-muted"> <p className="mt-3 text-xs text-muted">
{dateErr ? ( {dateErr ? (
<span className="text-bad">{dateErr}</span> <span className="text-bad">{dateErr}</span>
@ -184,7 +222,7 @@ export function RunReviewScreen() {
kind="secondary" kind="secondary"
onClick={() => run(true)} onClick={() => run(true)}
busy={busy === 'custom'} busy={busy === 'custom'}
disabled={busy !== null || !!dateErr || (!asOf && !from && !to)} disabled={busy !== null || !!dateErr || (mode === 'replay' ? !asOf : !from || !to)}
> >
<CalendarRange className="h-4 w-4" /> <CalendarRange className="h-4 w-4" />
Run with these dates Run with these dates