The cold-start surface showed "N/20 closed issues estimated", implying you're
just (20−N) closes away. But calibrationSamples silently drops closed+estimated
issues that closed in 0 working days (same-day closes) — real closes that
structurally can't calibrate. On this repo that's 10 of 24 closes hidden: the
note read 14/20 as if 6 away, when a third of the history will never count.
- core: `calibrationCoverage(issues, timelines, asOf)` → { candidates, usable,
excludedSameDay }, counting the silently-excluded same-day closes. Pure, tested.
- surface it: CalibrationData gains `excludedSameDay`; backlogCalibration returns
the coverage; the Runway note and the Calibration screen now say "… · N same-day
closes can't calibrate" so the thin sample is explained, not just reported.
Verified on christian/commitea: closed=24, usable=14, excludedSameDay=10.
131 core green (incl. new coverage test); core + desktop typecheck; 14 fixture e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
8.7 KiB
TypeScript
191 lines
8.7 KiB
TypeScript
import React from 'react'
|
||
|
||
import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js'
|
||
import { Badge, Card, Icon } from '../ui/index.js'
|
||
|
||
// Calibration report — estimate-vs-actual evidence behind the cones.
|
||
// `data` (real fit from closed-issue actuals) overrides the demo fixture.
|
||
export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) {
|
||
const c = data ?? CALIBRATION
|
||
|
||
// scatter chart geometry
|
||
const W = 420,
|
||
H = 300,
|
||
pad = { l: 36, r: 16, t: 14, b: 30 }
|
||
const maxD = 9
|
||
const X = (d: number) => pad.l + (d / maxD) * (W - pad.l - pad.r)
|
||
const Y = (d: number) => H - pad.b - (d / maxD) * (H - pad.t - pad.b)
|
||
|
||
const BiasBar = ({ bias }: { bias: number | null }) => {
|
||
if (bias == null)
|
||
return <span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>n too small</span>
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
|
||
<div style={{ position: 'relative', flex: 1, height: 10, background: 'var(--paper-2)', borderRadius: 3 }}>
|
||
<div style={{ position: 'absolute', left: '30%', top: -2, bottom: -2, width: 1.5, background: 'var(--line-2)' }}></div>
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: '30%',
|
||
top: 1.5,
|
||
height: 7,
|
||
width: `${Math.min(bias * 1.6, 66)}%`,
|
||
background: bias > 15 ? 'var(--warn)' : 'var(--ok)',
|
||
borderRadius: '0 3px 3px 0',
|
||
opacity: 0.75,
|
||
}}
|
||
></div>
|
||
</div>
|
||
<span style={{ font: '500 11.5px var(--font-mono)', color: bias > 15 ? 'var(--warn)' : 'var(--ok)', width: 42, textAlign: 'right' }}>
|
||
+{bias}%
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
background: 'none',
|
||
border: 'none',
|
||
font: '500 12.5px var(--font-sans)',
|
||
color: 'var(--ink-2)',
|
||
cursor: 'pointer',
|
||
padding: '2px 0',
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
<Icon name="arrow-left" size={14} /> Runway
|
||
</button>
|
||
<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 }}>Calibration</h1>
|
||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0', whiteSpace: 'nowrap' }}>{c.n} closed issues with estimates · evidence, not opinion</p>
|
||
</div>
|
||
{c.active ? (
|
||
<Badge tone="ok" dot>curve active · n ≥ 20</Badge>
|
||
) : (
|
||
<Badge tone="warn" dot>cold-start · {c.n}/20</Badge>
|
||
)}
|
||
</header>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, alignItems: 'start' }}>
|
||
{/* scatter */}
|
||
<Card overline="Estimate vs actual" title="The shape of hope" jade>
|
||
<svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block' }}>
|
||
{[1, 3, 5, 8].map((d) => (
|
||
<React.Fragment key={d}>
|
||
<line x1={X(d)} x2={X(d)} y1={pad.t} y2={H - pad.b} stroke="var(--line-1)" strokeWidth="1" />
|
||
<text x={X(d)} y={H - 12} textAnchor="middle" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
|
||
<line x1={pad.l} x2={W - pad.r} y1={Y(d)} y2={Y(d)} stroke="var(--line-1)" strokeWidth="1" />
|
||
<text x={pad.l - 6} y={Y(d) + 3} textAnchor="end" style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>{d}d</text>
|
||
</React.Fragment>
|
||
))}
|
||
{/* perfect line */}
|
||
<line x1={X(0)} y1={Y(0)} x2={X(maxD)} y2={Y(maxD)} stroke="var(--line-2)" strokeWidth="1.2" strokeDasharray="4 4" />
|
||
<text x={X(7.6)} y={Y(7.6) + 14} style={{ font: '400 10px var(--font-mono)', fill: 'var(--ink-3)' }}>honest</text>
|
||
{/* fit */}
|
||
<line x1={X(0)} y1={Y(0)} x2={X(maxD / c.fit)} y2={Y(maxD)} stroke="var(--cone-line)" strokeWidth="1.6" />
|
||
<text x={X(4.1)} y={Y(4.1 * c.fit) - 8} style={{ font: '500 10px var(--font-mono)', fill: 'var(--cone-line)' }}>you · ×{c.fit}</text>
|
||
{/* points */}
|
||
{c.scatter.map(([e, a], i) => (
|
||
<circle key={i} cx={X(e) + ((i % 5) - 2) * 3} cy={Y(a)} r="3" fill="var(--accent)" opacity="0.55" />
|
||
))}
|
||
</svg>
|
||
<p style={{ font: 'var(--text-caption)', color: 'var(--ink-3)', margin: '8px 0 0' }}>estimated (x) vs actual days (y) · actuals inferred from git events, never tracked</p>
|
||
</Card>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{/* per-label bias */}
|
||
<Card overline="Bias by estimate label" flush>
|
||
<div>
|
||
{c.labels.map((r, i) => (
|
||
<div
|
||
key={r.label}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 14,
|
||
padding: '10px 20px',
|
||
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
|
||
}}
|
||
>
|
||
<span style={{ font: '500 11.5px var(--font-mono)', color: 'var(--ink-1)', width: 52 }}>{r.label}</span>
|
||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)', width: 66 }}>n={r.n} · {r.median}</span>
|
||
<BiasBar bias={r.bias} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* per-person */}
|
||
<Card overline="By person" flush>
|
||
<div>
|
||
{c.people.map((p, i) => (
|
||
<div
|
||
key={p.who}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '11px 20px',
|
||
borderTop: i === 0 ? 'none' : '1px solid var(--line-1)',
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: '50%',
|
||
background: 'var(--spruce-2)',
|
||
color: 'var(--accent-text)',
|
||
font: '600 9px/24px var(--font-sans)',
|
||
textAlign: 'center',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{p.who
|
||
.split(' ')
|
||
.map((w) => w[0])
|
||
.join('')}
|
||
</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-1)' }}>{p.who}</span>
|
||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}> · n={p.n} · {p.note}</span>
|
||
</div>
|
||
<span style={{ font: '500 12px var(--font-mono)', color: p.bias > 15 ? 'var(--warn)' : 'var(--ok)' }}>+{p.bias}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* effect on forecasts */}
|
||
<Card overline="What this does to your forecasts">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
<span style={{ font: '400 12.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>{c.effect.raw}</span>
|
||
<Icon name="arrow-right" size={14} style={{ color: 'var(--ink-3)' }} />
|
||
<span style={{ font: '500 12.5px var(--font-mono)', color: 'var(--ink-1)', whiteSpace: 'nowrap' }}>{c.effect.banded}</span>
|
||
</div>
|
||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||
{c.active
|
||
? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.'
|
||
: 'Not enough closed history yet — I’m forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}
|
||
{!c.active && c.excludedSameDay > 0
|
||
? ` And ${c.excludedSameDay} closed ${c.excludedSameDay === 1 ? 'issue' : 'issues'} closed the same day they were started — 0 working days can’t calibrate, so they don’t count toward the 20.`
|
||
: ''}
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|