feat: Monte Carlo forecast → real burn-up cone (#10)
Replace the demo cone on Morning service with a real, seeded Monte Carlo forecast over the open backlog. The LLM never does this — it's plain, reproducible code (evidence-based scheduling). core (@commitea/core/forecast-v0): - Code-resident lognormal cold-start priors per estimate bucket (D3): sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long), sigma shrinks as tickets grow. Replaced by the team's empirical fit at n >= 20 (#5 supplies the actuals). - forecast(): seeded mulberry32 + Box-Muller over the scheduler's deterministic order (order is fixed from estimates/deps; only durations vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing + a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible. renderer: - lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData. - BurnUpCone gains a data-driven twin; falls back byte-identical to the fixture cone when no forecast (demo mode unchanged). - Focus card shows the real "80% of the open backlog lands by <range>", real scope count, and names the cold-start priors. v0 scope (each a later slice): single serial worker (capacity is #8); cold-start priors only (empirical fit is #5); no historical actual polyline (needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind badge) stays fixture until milestone due dates land. Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green, live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import type { BurnUpData } from '../../lib/dates.js'
|
||||
|
||||
/**
|
||||
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. The
|
||||
* fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the
|
||||
* shapes (cone from today, 80% band, actual polyline, today rule) are final.
|
||||
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a
|
||||
* `data` prop is supplied the cone is drawn from real Monte Carlo output
|
||||
* (#10); without it, the fixed sample paths below stand in (demo mode). The
|
||||
* shapes (cone from today, 80% band, today rule) are final.
|
||||
*/
|
||||
|
||||
export interface BurnUpConeProps {
|
||||
width?: number
|
||||
height?: number
|
||||
/** Real forecast geometry. When present it replaces the demo sample paths. */
|
||||
data?: BurnUpData
|
||||
}
|
||||
|
||||
export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) {
|
||||
export function BurnUpCone({ width = 640, height = 220, data }: BurnUpConeProps) {
|
||||
if (data) return <BurnUpConeReal width={width} height={height} data={data} />
|
||||
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
||||
const W = width - pad.l - pad.r
|
||||
const H = height - pad.t - pad.b
|
||||
@@ -89,6 +95,62 @@ export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Data-driven twin of BurnUpCone — same visual grammar, real forecast geometry. */
|
||||
function BurnUpConeReal({ width, height, data }: { width: number; height: number; data: BurnUpData }) {
|
||||
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
||||
const W = width - pad.l - pad.r
|
||||
const H = height - pad.t - pad.b
|
||||
const x = (day: number) => pad.l + (day / data.horizonDays) * W
|
||||
const y = (frac: number) => pad.t + (1 - frac) * H
|
||||
|
||||
const hi = data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`).join(' ')
|
||||
const lo = data.band.map((p) => `${x(p.loDay)},${y(p.fraction)}`).join(' ')
|
||||
const mid = data.band.map((p) => `${x(p.midDay)},${y(p.fraction)}`).join(' ')
|
||||
const cone = [
|
||||
...data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`),
|
||||
...[...data.band].reverse().map((p) => `${x(p.loDay)},${y(p.fraction)}`),
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
|
||||
))}
|
||||
{/* scope */}
|
||||
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
|
||||
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'}
|
||||
</text>
|
||||
{/* cone */}
|
||||
<polygon points={cone} fill="var(--cone-fill)" />
|
||||
<polyline points={hi} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
<polyline points={lo} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||
<polyline points={mid} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
|
||||
{/* origin = today, 0% of remaining scope */}
|
||||
<circle cx={x(0)} cy={y(0)} r="3.5" fill="var(--cone-actual)" />
|
||||
{/* today rule */}
|
||||
<line x1={x(0)} x2={x(0)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
|
||||
<text x={x(0) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
|
||||
today
|
||||
</text>
|
||||
{/* 80% band label */}
|
||||
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
|
||||
80%
|
||||
</text>
|
||||
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
|
||||
{data.rangeLabel}
|
||||
</text>
|
||||
{/* x labels */}
|
||||
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
{data.startLabel}
|
||||
</text>
|
||||
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||
{data.endLabel}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RunwayBarMilestone {
|
||||
tone: 'ok' | 'warn'
|
||||
pos: number
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
||||
import { type FocusView } from '../../lib/backlog.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 milestone burn-up cone.
|
||||
* `focus` (real scheduler output) overrides the demo fixture when gitea is
|
||||
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice).
|
||||
* 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 }) => (
|
||||
@@ -94,13 +96,21 @@ export function FocusScreen({
|
||||
</div>
|
||||
|
||||
<Card
|
||||
overline="Milestone · Beta"
|
||||
title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>}
|
||||
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 />
|
||||
<BurnUpCone data={forecast?.cone} />
|
||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||
The cone has narrowed since Friday. I’m quietly pleased.
|
||||
{forecast
|
||||
? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — the cone tightens as the team closes work.`
|
||||
: 'The cone has narrowed since Friday. I’m quietly pleased.'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
|
||||
import logoIcon from '../../design/assets/logo-icon.png'
|
||||
import type { IssueRef } from '../../data/fixtures.js'
|
||||
import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||
import { forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||
import { useBacklog } from '../../lib/use-backlog.js'
|
||||
import { PrimitivesGallery } from '../gallery.js'
|
||||
import { BoardScreen } from '../screens/board-screen.js'
|
||||
@@ -88,6 +88,8 @@ export function AppShell() {
|
||||
const backlog = useBacklog()
|
||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined
|
||||
const forecast =
|
||||
backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
@@ -158,7 +160,7 @@ export function AppShell() {
|
||||
const renderScreen = () => {
|
||||
switch (view) {
|
||||
case 'focus':
|
||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} />
|
||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
|
||||
case 'standup':
|
||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||
case 'board':
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type DependencyEdge,
|
||||
forecast,
|
||||
type GiteaIssue,
|
||||
inferColumnV0,
|
||||
type LifecycleColumn,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from '@commitea/core'
|
||||
|
||||
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||
import { type BurnUpData, buildBurnUpData } from './dates.js'
|
||||
|
||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||
diagnosis: 'Diagnosis',
|
||||
@@ -57,23 +59,48 @@ function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
|
||||
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale }
|
||||
}
|
||||
|
||||
function toSchedulable(issues: GiteaIssue[]) {
|
||||
return issues
|
||||
.filter((i) => i.state === 'open')
|
||||
.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface ForecastView {
|
||||
scope: number
|
||||
cone: BurnUpData
|
||||
p80Label: string
|
||||
rangeLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored
|
||||
* burn-up cone. Returns null when there's nothing to forecast (no open scope) —
|
||||
* the UI then falls back to the demo cone. `today` is injectable for tests.
|
||||
*/
|
||||
export function forecastBacklog(
|
||||
issues: GiteaIssue[],
|
||||
deps: DependencyEdge[],
|
||||
today: Date = new Date(),
|
||||
): ForecastView | null {
|
||||
const f = forecast(toSchedulable(issues), deps)
|
||||
const cone = buildBurnUpData(f, today)
|
||||
if (!cone) return null
|
||||
return { scope: f.scope, cone, p80Label: cone.p80Label, rangeLabel: cone.rangeLabel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the deterministic scheduler over the open backlog and take the top three
|
||||
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||
* edges come from gitea's native issue dependencies.
|
||||
*/
|
||||
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const plan = schedule(
|
||||
open.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
})),
|
||||
deps,
|
||||
)
|
||||
const plan = schedule(toSchedulable(issues), deps)
|
||||
const f = selectFocus(plan)
|
||||
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
||||
}
|
||||
|
||||
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Forecast } from '@commitea/core'
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
/** Advance `base` by whole working days (skipping Sat/Sun). Fractions round to nearest day. */
|
||||
export function addWorkingDays(base: Date, workingDays: number): Date {
|
||||
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate())
|
||||
let remaining = Math.max(0, Math.round(workingDays))
|
||||
while (remaining > 0) {
|
||||
d.setDate(d.getDate() + 1)
|
||||
const day = d.getDay()
|
||||
if (day !== 0 && day !== 6) remaining -= 1
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** "Mar 6" */
|
||||
export function formatShort(d: Date): string {
|
||||
return `${MONTHS[d.getMonth()]} ${d.getDate()}`
|
||||
}
|
||||
|
||||
/** "Feb 24 – Mar 6" (collapses the month when both ends share it → "Mar 3–12"). */
|
||||
export function formatRange(lo: Date, hi: Date): string {
|
||||
if (lo.getMonth() === hi.getMonth()) return `${MONTHS[lo.getMonth()]} ${lo.getDate()}–${hi.getDate()}`
|
||||
return `${formatShort(lo)} – ${formatShort(hi)}`
|
||||
}
|
||||
|
||||
export interface BurnUpBandPoint {
|
||||
fraction: number
|
||||
loDay: number
|
||||
midDay: number
|
||||
hiDay: number
|
||||
}
|
||||
|
||||
/** Chart-ready geometry + calendar labels derived from a Monte Carlo forecast. */
|
||||
export interface BurnUpData {
|
||||
scope: number
|
||||
horizonDays: number
|
||||
band: BurnUpBandPoint[]
|
||||
p80Day: number
|
||||
p80Label: string
|
||||
rangeLabel: string
|
||||
startLabel: string
|
||||
endLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a working-day forecast onto a calendar-anchored burn-up cone. `today`
|
||||
* anchors day 0; the cone emanates from (today, 0) and widens to the right.
|
||||
* The historical "actual" polyline behind today awaits lifecycle events (#5).
|
||||
*/
|
||||
export function buildBurnUpData(f: Forecast, today: Date): BurnUpData | null {
|
||||
if (f.scope === 0 || f.curve.length === 0) return null
|
||||
const last = f.curve[f.curve.length - 1]
|
||||
const horizonDays = Math.max(1, Math.ceil(last.p90Day * 1.05))
|
||||
const band: BurnUpBandPoint[] = [
|
||||
{ fraction: 0, loDay: 0, midDay: 0, hiDay: 0 },
|
||||
...f.curve.map((p) => ({ fraction: p.fraction, loDay: p.p10Day, midDay: p.p50Day, hiDay: p.p90Day })),
|
||||
]
|
||||
return {
|
||||
scope: f.scope,
|
||||
horizonDays,
|
||||
band,
|
||||
p80Day: f.p80Day,
|
||||
p80Label: formatShort(addWorkingDays(today, f.p80Day)),
|
||||
rangeLabel: formatRange(addWorkingDays(today, f.p50Day), addWorkingDays(today, last.p90Day)),
|
||||
startLabel: formatShort(today),
|
||||
endLabel: formatShort(addWorkingDays(today, horizonDays)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user