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