Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual history (the working time #5 infers from git events) instead of guessing forever. core (@commitea/core/calibration-v0): - fitCalibration(samples): lognormal fit on log(actual/estimate) — global + per-bucket (once a bucket clears the floor) + per-person bias. coldStart until n >= 20 closed-with-estimate issues. - calibrationSamples(): pull those samples from the closed backlog via lifecycle inference (estimate label vs inferred actualWorkingDays). - toDurationModel(): project the fit to the params forecast consumes. - forecast() gains options.model: when past cold-start, fitted params drive the sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart now reflects the model. nearestBucket extracted + exported. app: - AppShell fits calibration once from the reconciled backlog, feeds the model into forecastBacklog (cone), and drives the Calibration screen + Runway header. - Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20) vs calibrated (on N closed) from real data; Calibration scatter / bucket bias / per-person all fitted, degrading honestly on a thin dataset. Known refinement: same-day closes yield 0 working-day actuals (day-granular) and are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional (hours-based) actual would let those count. Per-person uses gitea login, not display name, until the person map lands. Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their stacked base branches but never propagated to main (stacked-merge trap); this branch is cut from main and carries all three so main is whole again. Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start calibration surface (Runway note + screen badge fitted from actuals). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
import React from 'react'
|
||
|
||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
||
import { type ForecastView, type FocusView } from '../../lib/backlog.js'
|
||
import { BurnUpCone } from '../charts/chart.js'
|
||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||
|
||
/**
|
||
* Morning service — the Now/Next/Later focus cards + the burn-up cone.
|
||
* `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures
|
||
* when gitea is configured; both fall back to the handoff demo otherwise.
|
||
*/
|
||
export function FocusScreen({
|
||
onOpenIssue,
|
||
focus,
|
||
forecast,
|
||
}: {
|
||
onOpenIssue: (issue: IssueRef) => void
|
||
focus?: FocusView
|
||
forecast?: ForecastView
|
||
}) {
|
||
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||
<Card
|
||
overline={slot}
|
||
jade={jade}
|
||
title={
|
||
<a
|
||
href="#"
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
onOpenIssue({ id: issue.id, title: issue.title, labels: issue.labels })
|
||
}}
|
||
style={{ color: 'inherit', border: 'none' }}
|
||
>
|
||
{issue.title}
|
||
</a>
|
||
}
|
||
actions={<IconButton icon="ellipsis" label="More" size="sm" />}
|
||
footer={
|
||
jade ? (
|
||
<>
|
||
<Button size="sm">Start</Button>
|
||
<Button size="sm" variant="ghost">
|
||
Defer
|
||
</Button>
|
||
<span style={{ marginLeft: 'auto', font: 'var(--text-caption)', color: 'var(--ink-3)' }}>
|
||
scheduler pick · critical path
|
||
</span>
|
||
</>
|
||
) : null
|
||
}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||
<span style={{ font: 'var(--text-data)', color: 'var(--ink-3)' }}>#{issue.id}</span>
|
||
{issue.labels.map((l) => (
|
||
<Tag key={l} label={l} />
|
||
))}
|
||
{issue.steeping ? (
|
||
<Badge tone="warn" dot>
|
||
steeping {issue.steeping}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>{issue.rationale}</p>
|
||
</Card>
|
||
)
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<header
|
||
style={{
|
||
borderBottom: 'var(--rule-double)',
|
||
paddingBottom: 14,
|
||
display: 'flex',
|
||
alignItems: 'baseline',
|
||
justifyContent: 'space-between',
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div>
|
||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>Morning service</h1>
|
||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>
|
||
{TODAY} · reconcile 3.2s
|
||
</p>
|
||
</div>
|
||
<Badge tone="ok" dot>
|
||
ahead of forecast
|
||
</Badge>
|
||
</header>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
||
{view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
|
||
{view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
|
||
{view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
|
||
</div>
|
||
|
||
<Card
|
||
overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
|
||
title={
|
||
forecast ? (
|
||
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
|
||
) : (
|
||
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>
|
||
)
|
||
}
|
||
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
||
>
|
||
<BurnUpCone data={forecast?.cone} />
|
||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||
{forecast
|
||
? forecast.coldStart
|
||
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.`
|
||
: `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.`
|
||
: 'The cone has narrowed since Friday. I’m quietly pleased.'}
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|