diff --git a/src/data/sample.ts b/src/data/sample.ts new file mode 100644 index 0000000..ffa635a --- /dev/null +++ b/src/data/sample.ts @@ -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"] +` diff --git a/src/model/parse.ts b/src/model/parse.ts new file mode 100644 index 0000000..1dacdde --- /dev/null +++ b/src/model/parse.ts @@ -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 + try { + data = parseToml(source) as Record + } 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[] { + if (value == null) return [] + if (!Array.isArray(value)) { + throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`) + } + return value as Record[] +} + +function parseFeature(f: Record, 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, 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)`) + } +} diff --git a/src/model/plan.ts b/src/model/plan.ts new file mode 100644 index 0000000..d6796c4 --- /dev/null +++ b/src/model/plan.ts @@ -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, + } +} diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..4e1f385 --- /dev/null +++ b/src/model/types.ts @@ -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 +} diff --git a/src/model/week.ts b/src/model/week.ts new file mode 100644 index 0000000..add7257 --- /dev/null +++ b/src/model/week.ts @@ -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', + }) +}