diff --git a/package.json b/package.json index 4c579f5..75a648a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "retrobus": "^1.9.4", "sanitize-html": "^2.17.0", "shikiji-core": "0.10.2", + "smol-toml": "^1.7.0", + "valibot": "^1.4.2", "vue": "^3.5.18", "vue-i18n": "^11.1.11", "vue-router": "^4.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 438e62c..681c708 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,12 @@ importers: shikiji-core: specifier: 0.10.2 version: 0.10.2 + smol-toml: + specifier: ^1.7.0 + version: 1.7.0 + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) vue: specifier: ^3.5.18 version: 3.5.18(typescript@5.9.3) @@ -5933,6 +5939,10 @@ packages: resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} engines: {node: '>=20.0.0'} + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + snapdragon-node@2.1.1: resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==} engines: {node: '>=0.10.0'} @@ -6449,6 +6459,14 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -13005,6 +13023,8 @@ snapshots: smob@1.6.1: {} + smol-toml@1.7.0: {} + snapdragon-node@2.1.1: dependencies: define-property: 1.0.0 @@ -13533,6 +13553,10 @@ snapshots: uuid@8.3.2: {} + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/src/modules/macroplan/components/MacroplanGrid.vue b/src/modules/macroplan/components/MacroplanGrid.vue new file mode 100644 index 0000000..bd8af91 --- /dev/null +++ b/src/modules/macroplan/components/MacroplanGrid.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/src/modules/macroplan/model/parse.ts b/src/modules/macroplan/model/parse.ts new file mode 100644 index 0000000..83b3ab4 --- /dev/null +++ b/src/modules/macroplan/model/parse.ts @@ -0,0 +1,153 @@ +import { parse as parseToml } from "smol-toml" +import * as v from "valibot" + +import type { RawPlan, StatusLevel } from "./types" +import { toYmd } from "./week" + +/** 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 = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[] + +// ── Field schemas ────────────────────────────────────────────────────────── +// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string, +// normalized to yyyy-mm-dd via `toYmd`. +const Ymd = v.pipe( + v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"), + v.transform((value: string | Date) => toYmd(value)), +) + +const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`) + +const Name = v.pipe(v.string("is required"), v.nonEmpty("is required")) + +const FeatureSchema = v.object({ + name: Name, + start: Ymd, + original: Ymd, + reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []), + delivered: v.optional(Ymd), + learning: v.optional(v.string()), + status: v.optional(Status), + note: v.optional(v.string()), +}) + +const MilestoneSchema = v.object({ + name: Name, + week: Ymd, + requires: v.optional( + v.array(v.string("must be a feature name"), "must be a list of feature names"), + [], + ), +}) + +/** The Macroplan format version this build understands (see docs/format.md). + * Files may declare `macroplan_version`; a newer version is rejected rather + * than silently mis-rendered against a schema we don't know. */ +export const FORMAT_VERSION = 1 + +/** 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)) + } + + checkVersion(data.macroplan_version) + + const features = asBlocks(data.feature, "feature").map((f, i) => + check(FeatureSchema, f, blockWhere("feature", f, i)), + ) + requireUniqueFeatureNames(features) + + return { + title: data.title != null ? String(data.title) : "Untitled Macroplan", + start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined, + end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined, + features, + milestones: asBlocks(data.milestone, "milestone").map((m, i) => + check(MilestoneSchema, m, blockWhere("milestone", m, i)), + ), + } +} + +/** Validate the optional `macroplan_version` marker. Absent means the current + * version; a future version we don't understand is rejected rather than + * silently mis-rendered. */ +function checkVersion(value: unknown): void { + if (value == null) return + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new PlanParseError("`macroplan_version` must be a positive integer") + } + if (value > FORMAT_VERSION) { + throw new PlanParseError( + `unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`, + ) + } +} + +/** Feature names are the join key for a Milestone's `requires`, so they must be + * unique — otherwise a requirement resolves ambiguously to the first match. */ +function requireUniqueFeatureNames(features: readonly { name: string }[]): void { + const seen = new Set() + for (const f of features) { + if (seen.has(f.name)) { + throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`) + } + seen.add(f.name) + } +} + +function asBlocks(value: unknown, key: string): unknown[] { + if (value == null) return [] + if (!Array.isArray(value)) { + throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`) + } + return value +} + +/** "feature \"Payments\"" when the block carries a name, else "feature #2". */ +function blockWhere(kind: string, block: unknown, i: number): string { + const name = + block != null && typeof block === "object" && "name" in block + ? (block as { name: unknown }).name + : undefined + return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}` +} + +/** Validate `value` against `schema`, raising a contextual PlanParseError. */ +function check( + schema: S, + value: unknown, + where: string, + field?: string, +): v.InferOutput { + const result = v.safeParse(schema, value) + if (!result.success) { + throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`) + } + return result.output +} + +type Issue = { + readonly message: string + readonly received: string + readonly path?: ReadonlyArray<{ readonly key: unknown }> +} + +/** Render an issue against the offending field, e.g. "`start` must be a date" + * — or "missing `original`" when the key is absent (valibot reports its own + * "Invalid key" wording for that, which isn't fit to show the author). */ +function friendly(issue: Issue, fallbackField?: string): string { + const key = issue.path?.[0]?.key + const field = typeof key === "string" ? key : fallbackField + if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value" + return field ? `\`${field}\` ${issue.message}` : issue.message +} diff --git a/src/modules/macroplan/model/plan.spec.ts b/src/modules/macroplan/model/plan.spec.ts new file mode 100644 index 0000000..2181aae --- /dev/null +++ b/src/modules/macroplan/model/plan.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import { parseMacroplan, PlanParseError } from "./parse" +import { buildPlan } from "./plan" + +const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15 + +const SOURCE = ` +title = "Q3 plan" + +[[feature]] +name = "Auth" +start = 2026-06-01 +original = 2026-06-15 +delivered = 2026-06-15 + +[[feature]] +name = "Payments" +start = 2026-06-01 +original = 2026-06-08 +reestimates = [2026-06-22] +status = "at-risk" + +[[milestone]] +name = "MVP" +week = 2026-06-15 +requires = ["Auth", "Payments"] +` + +describe("macroplan model", () => { + it("derives a render-ready plan from TOML", () => { + const plan = buildPlan(parseMacroplan(SOURCE), TODAY) + + expect(plan.title).toBe("Q3 plan") + expect(plan.weeks).toEqual([ + "2026-06-01", + "2026-06-08", + "2026-06-15", + "2026-06-22" + ]) + expect(plan.nowWeek).toBe("2026-06-15") + + const auth = plan.rows.find((r) => r.name === "Auth") + expect(auth?.onTime).toBe(true) + expect(auth?.markers).toContainEqual({ + week: "2026-06-15", + kind: "delivered-on-time" + }) + + const payments = plan.rows.find((r) => r.name === "Payments") + expect(payments?.delivered).toBe(false) + expect(payments?.slipCount).toBe(1) + + expect(plan.milestones[0].unmet).toEqual(["Payments"]) + }) + + it("rejects malformed sources with an author-safe error", () => { + expect(() => parseMacroplan('[[feature]]\nname = "No dates"')).toThrow( + PlanParseError + ) + }) +}) diff --git a/src/modules/macroplan/model/plan.ts b/src/modules/macroplan/model/plan.ts new file mode 100644 index 0000000..385a2f4 --- /dev/null +++ b/src/modules/macroplan/model/plan.ts @@ -0,0 +1,87 @@ +import type { FeatureRow, Marker, MilestoneLine,Plan, RawFeature, RawPlan } from "./types" +import { mondayOf, type WeekId,weekRange } from "./week" + +/** + * 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 nowWeek = mondayOf(today) + const rows = raw.features.map((f) => buildRow(f, nowWeek)) + + 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. + // Optional authored `start`/`end` widen this span with lead-in / trailing weeks; + // they only ever extend it — a Feature or marker outside them is never clipped. + const allWeeks: WeekId[] = [] + if (raw.start != null) allWeeks.push(mondayOf(raw.start)) + if (raw.end != null) allWeeks.push(mondayOf(raw.end)) + 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, nowWeek: WeekId): 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 intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b)) + // A delivered Feature's bar ends at its delivery. An undelivered Feature that + // is already past its furthest estimate keeps "running" up to now (overdue), + // so its bar extends to the now week; otherwise it ends at the last marker. + const barEndWeek = delivered || intrinsicEnd > nowWeek ? intrinsicEnd : nowWeek + + 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/modules/macroplan/model/types.ts b/src/modules/macroplan/model/types.ts new file mode 100644 index 0000000..8242113 --- /dev/null +++ b/src/modules/macroplan/model/types.ts @@ -0,0 +1,72 @@ +import type { WeekId } from "./week" + +export type StatusLevel = "on-track" | "at-risk" | "off-track" + +// ── 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 + start?: string // yyyy-mm-dd — optional authored left edge of the plan's span + end?: string // yyyy-mm-dd — optional authored right edge of the plan's span + 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/modules/macroplan/model/week.ts b/src/modules/macroplan/model/week.ts new file mode 100644 index 0000000..b65c6c9 --- /dev/null +++ b/src/modules/macroplan/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", + }) +}