feat(model): parse TOML plans and derive the Plan model

On-time vs. late is judged against the Original Estimate only, never a
re-estimate (ADR-0001); weeks are keyed by their Monday.
This commit is contained in:
Julien Calixte
2026-06-17 00:38:16 +02:00
parent 2c0e9c360f
commit c1d72184eb
5 changed files with 343 additions and 0 deletions

50
src/data/sample.ts Normal file
View File

@@ -0,0 +1,50 @@
// Default Macroplan shown on first load — exercises every state:
// on-time delivery, late delivery with slips, in-flight (green/orange/red),
// an overdue Feature, and a Milestone with unmet required Features.
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
# A Feature: start week, Original Estimate (the immovable baseline), then any
# Re-estimates (slips), an optional Delivery, and an optional Learning / Status.
# Dates are TOML date literals; any day is snapped to that week's Monday.
[[feature]]
name = "Auth"
start = 2026-06-01
original = 2026-06-15
delivered = 2026-06-15 # on time → ◉
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
[[feature]]
name = "Payments"
start = 2026-06-01
original = 2026-06-15 # ◯ baseline
reestimates = [2026-06-29, 2026-07-13] # two slips → △ △
delivered = 2026-07-20 # after the baseline → ▲ late
learning = "Vendor lead time was the real constraint — derisk vendors up front."
[[feature]]
name = "Dashboard"
start = 2026-06-01
original = 2026-06-08 # already past 'now' and undelivered → overdue
status = "red"
note = "No recovery plan yet — needs an owner."
[[feature]]
name = "Search"
start = 2026-06-08
original = 2026-06-22
reestimates = [2026-07-06] # slipped once, still in flight → △
status = "orange"
note = "Third-party search API is flaky; spike a fallback."
[[feature]]
name = "Notifications"
start = 2026-06-22
original = 2026-07-06
status = "green"
[[milestone]]
name = "MVP go-live"
week = 2026-07-06
requires = ["Auth", "Payments", "Dashboard"]
`

85
src/model/parse.ts Normal file
View File

@@ -0,0 +1,85 @@
import { parse as parseToml } from 'smol-toml'
import { toYmd } from './week'
import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types'
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'PlanParseError'
}
}
const STATUSES: StatusLevel[] = ['green', 'orange', 'red']
/** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan {
let data: Record<string, unknown>
try {
data = parseToml(source) as Record<string, unknown>
} catch (e) {
throw new PlanParseError(e instanceof Error ? e.message : String(e))
}
const features = asBlocks(data.feature, 'feature').map(parseFeature)
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
features,
milestones,
}
}
function asBlocks(value: unknown, key: string): Record<string, unknown>[] {
if (value == null) return []
if (!Array.isArray(value)) {
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
}
return value as Record<string, unknown>[]
}
function parseFeature(f: Record<string, unknown>, i: number): RawFeature {
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
}
if (f.reestimates != null && !Array.isArray(f.reestimates)) {
throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`)
}
return {
name: String(f.name),
start: toYmdOr(where, 'start', f.start),
original: toYmdOr(where, 'original', f.original),
reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)),
delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined,
learning: f.learning != null ? String(f.learning) : undefined,
status: f.status as StatusLevel | undefined,
note: f.note != null ? String(f.note) : undefined,
}
}
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
if (m.requires != null && !Array.isArray(m.requires)) {
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
}
return {
name: String(m.name),
week: toYmdOr(where, 'week', m.week),
requires: ((m.requires as unknown[]) ?? []).map(String),
}
}
function toYmdOr(where: string, field: string, value: unknown): string {
try {
return toYmd(value)
} catch {
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
}
}

79
src/model/plan.ts Normal file
View File

@@ -0,0 +1,79 @@
import { mondayOf, weekRange, type WeekId } from './week'
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from './types'
/**
* Derive the render-ready Plan from the raw model (component C2).
*
* On-time vs. late is judged ONLY against the Original Estimate, never a
* Re-estimate (ADR-0001). The full slip history (`◯` + every `△`) is preserved.
*/
export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan {
const rows = raw.features.map(buildRow)
const nowWeek = mondayOf(today)
const milestones = raw.milestones.map((m): MilestoneLine => {
const week = mondayOf(m.week)
const unmet = m.requires.filter((name) => {
const row = rows.find((r) => r.name === name)
if (!row) return true // references an unknown Feature → can't be met
const delivery = row.markers.find(
(x) => x.kind === 'delivered-on-time' || x.kind === 'delivered-late',
)
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
})
return { name: m.name, week, requires: m.requires, unmet }
})
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
const allWeeks: WeekId[] = []
for (const r of rows) {
allWeeks.push(r.startWeek, r.barEndWeek)
for (const mk of r.markers) allWeeks.push(mk.week)
}
for (const m of milestones) allWeeks.push(m.week)
let weeks: WeekId[] = []
if (allWeeks.length) {
const start = allWeeks.reduce((a, b) => (a < b ? a : b))
const end = allWeeks.reduce((a, b) => (a > b ? a : b))
weeks = weekRange(start, end)
}
const nowInRange = weeks.length > 0 && nowWeek >= weeks[0] && nowWeek <= weeks[weeks.length - 1]
return { title: raw.title, weeks, rows, milestones, nowWeek, nowInRange }
}
function buildRow(f: RawFeature): FeatureRow {
const startWeek = mondayOf(f.start)
const originalWeek = mondayOf(f.original)
const deliveredWeek = f.delivered ? mondayOf(f.delivered) : undefined
const delivered = deliveredWeek != null
// ADR-0001: compare the Delivery week to the Original Estimate week only.
const onTime = delivered ? deliveredWeek! <= originalWeek : null
const markers: Marker[] = []
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: 'reestimate' })
if (delivered) {
markers.push({ week: deliveredWeek!, kind: onTime ? 'delivered-on-time' : 'delivered-late' })
}
// The Original Estimate `◯` stands unless an on-time/early delivery already
// occupies (or precedes) it — then the delivery marker speaks for it.
if (!(delivered && onTime)) {
markers.push({ week: originalWeek, kind: 'original' })
}
const barEndWeek = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))
return {
name: f.name,
startWeek,
barEndWeek,
markers,
delivered,
onTime,
status: f.status,
note: f.note,
learning: f.learning,
slipCount: f.reestimates.length,
}
}

70
src/model/types.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { WeekId } from './week'
export type StatusLevel = 'green' | 'orange' | 'red'
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
export interface RawFeature {
name: string
start: string // yyyy-mm-dd
original: string // yyyy-mm-dd — the Original Estimate
reestimates: string[] // yyyy-mm-dd[]
delivered?: string // yyyy-mm-dd
learning?: string
status?: StatusLevel
note?: string
}
export interface RawMilestone {
name: string
week: string // yyyy-mm-dd
requires: string[] // Feature names
}
export interface RawPlan {
title: string
features: RawFeature[]
milestones: RawMilestone[]
}
// ── Derived model: render-ready (C2 output) ──
export type MarkerKind =
| 'original' // ◯ Original Estimate, not yet delivered
| 'reestimate' // △ a slip to a later week
| 'delivered-on-time' // ◉ delivered on/before the Original Estimate
| 'delivered-late' // ▲ delivered after the Original Estimate
export interface Marker {
week: WeekId
kind: MarkerKind
}
export interface FeatureRow {
name: string
startWeek: WeekId
barEndWeek: WeekId
markers: Marker[]
delivered: boolean
onTime: boolean | null // null while in-flight
status?: StatusLevel
note?: string
learning?: string
slipCount: number // number of Re-estimates
}
export interface MilestoneLine {
name: string
week: WeekId
requires: string[]
unmet: string[] // required Features not delivered on/before this week
}
export interface Plan {
title: string
weeks: WeekId[]
rows: FeatureRow[]
milestones: MilestoneLine[]
nowWeek: WeekId
nowInRange: boolean
}

59
src/model/week.ts Normal file
View File

@@ -0,0 +1,59 @@
// Week math. A "Week" is identified by the ISO date (yyyy-mm-dd) of its Monday.
// ISO yyyy-mm-dd strings sort lexicographically == chronologically, so plain
// string comparison (`<`, `<=`) is valid week ordering — we lean on that.
export type WeekId = string // 'yyyy-mm-dd', always a Monday
/**
* Extract a yyyy-mm-dd string from a TOML date (smol-toml's `TomlDate.toISOString()`
* returns the authored local date, e.g. "2026-06-01") or a plain string.
*/
export function toYmd(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10)
if (typeof value === 'string') return value.slice(0, 10)
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
}
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
// timezone boundary.
function utcNoon(ymd: string): Date {
const [y, m, d] = ymd.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d, 12))
}
function fmt(dt: Date): string {
return dt.toISOString().slice(0, 10)
}
/** The Monday (yyyy-mm-dd) of the ISO week containing the given date. */
export function mondayOf(value: unknown): WeekId {
const dt = utcNoon(toYmd(value))
const dow = dt.getUTCDay() // 0=Sun .. 6=Sat
const shift = dow === 0 ? -6 : 1 - dow
dt.setUTCDate(dt.getUTCDate() + shift)
return fmt(dt)
}
/** Add n weeks to a Monday WeekId (n may be negative). */
export function addWeeks(week: WeekId, n: number): WeekId {
const dt = utcNoon(week)
dt.setUTCDate(dt.getUTCDate() + n * 7)
return fmt(dt)
}
/** Inclusive list of Monday WeekIds from start..end (both must already be Mondays). */
export function weekRange(start: WeekId, end: WeekId): WeekId[] {
if (start > end) return [start]
const weeks: WeekId[] = []
for (let w = start; w <= end; w = addWeeks(w, 1)) weeks.push(w)
return weeks
}
/** Short column label for a week, e.g. "Jun 15". */
export function weekLabel(week: WeekId): string {
return utcNoon(week).toLocaleDateString('en-US', {
month: 'short',
day: '2-digit',
timeZone: 'UTC',
})
}