Files
commitea/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx
Croissant Le Doux 89c873b368 calibration: count same-day closes honestly (#34)
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>
2026-07-09 15:18:16 -04:00

191 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 — Im 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 cant calibrate, so they dont count toward the 20.`
: ''}
</p>
</Card>
</div>
</div>
</div>
)
}