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:
50
src/data/sample.ts
Normal file
50
src/data/sample.ts
Normal 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
85
src/model/parse.ts
Normal 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
79
src/model/plan.ts
Normal 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
70
src/model/types.ts
Normal 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
59
src/model/week.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user