feat(macroplan): vendor plan model and grid from the macroplan app

Copied verbatim (imports resorted) from jclab/macroplan so future
upstream syncs stay copy-paste; extraction to a shared package is
deferred until drift hurts.
This commit is contained in:
Julien Calixte
2026-07-08 00:01:12 +02:00
parent 5386fa66ea
commit 70f9a6b718
8 changed files with 891 additions and 0 deletions

View File

@@ -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",

24
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -0,0 +1,432 @@
<script setup lang="ts">
import { computed } from "vue"
import type { FeatureRow, MarkerKind,Plan } from "../model/types"
import { type WeekId,weekLabel } from "../model/week"
const props = defineProps<{ plan: Plan }>()
type Tone = "success" | "warning" | "error" | "neutral"
const GLYPH: Record<MarkerKind, string> = {
original: "◯",
reestimate: "△",
"delivered-on-time": "◉",
"delivered-late": "▲",
}
const MARKER_CLASS: Record<MarkerKind, string> = {
original: "text-base-content/50",
reestimate: "text-warning",
"delivered-on-time": "text-success",
"delivered-late": "text-error",
}
// When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = {
"delivered-late": 3,
"delivered-on-time": 3,
reestimate: 2,
original: 1,
}
const TONE_TEXT: Record<Tone, string> = {
success: "text-success",
warning: "text-warning",
error: "text-error",
neutral: "text-primary",
}
const TONE_BORDER: Record<Tone, string> = {
success: "border-success",
warning: "border-warning",
error: "border-error",
neutral: "border-primary",
}
const TONE_DOT: Record<Tone, string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
neutral: "bg-base-300",
}
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16
const WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
const weeks = computed(() => props.plan.weeks)
const gridStyle = computed(() => ({
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
}))
function tone(row: FeatureRow): Tone {
if (row.delivered) return row.onTime ? "success" : "error"
if (row.status === "on-track") return "success"
if (row.status === "at-risk") return "warning"
if (row.status === "off-track") return "error"
return "neutral"
}
function statusWord(row: FeatureRow): string {
return row.status ? row.status.replace("-", " ") : "in flight"
}
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
let best: MarkerKind | null = null
for (const m of row.markers) {
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
}
return best
}
interface Cell {
// how far the bar line runs within this cell: none, center→right,
// left→center, or full width
line: "none" | "right" | "left" | "full"
isStart: boolean
glyph: string
glyphCls: string
}
// rows × weeks render matrix, computed once.
const matrix = computed<Cell[][]>(() =>
props.plan.rows.map((row) =>
weeks.value.map((w) => {
const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell["line"] = "none"
if (inBar) {
const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
}
return {
line,
isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : "",
glyphCls: m ? MARKER_CLASS[m] : "",
}
}),
),
)
// Per-week column metadata for the header + the now / milestone column rules.
const cols = computed(() =>
weeks.value.map((w) => ({
w,
label: weekLabel(w),
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
isMilestone: props.plan.milestones.some((m) => m.week === w),
})),
)
function colClass(i: number): string {
const c = cols.value[i]
if (c.isNow) return "col-now"
if (c.isMilestone) return "col-ms"
return ""
}
// Milestone label flags above the axis, greedily stacked onto extra rows so
// labels in nearby weeks never overlap. `col` is the 1-based grid column.
const milestoneFlags = computed(() => {
const items = props.plan.milestones
.map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 }))
.filter((x) => x.col >= 2)
.sort((a, b) => a.col - b.col)
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
return items.map((x) => {
const startX = NAME_W + (x.col - 2) * WK
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
const width = text.length * BAND_CHAR + 14
let row = 0
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
rowEnd[row] = startX + width
const title = x.m.unmet.length
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
: `${x.m.name} — all required features met`
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
})
})
const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0))
const bandStyle = computed(() => ({
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`,
gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`,
}))
</script>
<template>
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
<!-- milestone band: stacked label flags with leader lines down to the axis -->
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
<span class="ms-dia"></span> {{ f.name
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
</span>
</template>
</div>
<div class="plan-grid" :style="gridStyle">
<!-- header row -->
<div class="hcell corner">Feature</div>
<div
v-for="(c, ci) in cols"
:key="'h-' + c.w"
class="hcell wkhead"
:class="[colClass(ci), { 'now-head': c.isNow }]"
>
<span class="wklabel">{{ c.label }}</span>
<span v-if="c.isNow" class="badge-now">now</span>
</div>
<div class="hcell learnhead">Learning / Status</div>
<!-- feature rows -->
<template v-for="(row, ri) in plan.rows" :key="row.name">
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
<span class="truncate" :title="row.name">{{ row.name }}</span>
</div>
<div
v-for="(cell, ci) in matrix[ri]"
:key="row.name + '-' + ci"
class="cell"
:class="colClass(ci)"
>
<i v-if="cell.line !== 'none'" class="bar" :class="[cell.line, TONE_TEXT[tone(row)]]"></i>
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
</div>
<div class="learncell">
<template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{
row.onTime ? "delivered on time" : "delivered late"
}}</span>
</template>
<template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
</template>
</div>
</template>
</div>
</div>
<!-- legend -->
<div class="legend">
<span><b class="text-base-content/50"></b> original estimate</span>
<span><b class="text-warning"></b> re-estimate</span>
<span><b class="text-success"></b> on time</span>
<span><b class="text-error"></b> late</span>
<span><b></b> feature bar</span>
<span><b class="ms-dia"></b> milestone</span>
<span><b class="now-swatch"></b> today</span>
</div>
</template>
<style scoped>
.macroplan {
--name-w: 9rem;
--wk: 3.5rem;
}
.plan-grid {
display: grid;
width: max-content;
min-width: 100%;
font-variant-ligatures: none;
}
/* ── Milestone band ───────────────────────────────────────────────── */
.ms-band {
display: grid;
width: max-content;
min-width: 100%;
padding-top: 0.35rem;
font-variant-ligatures: none;
}
.ms-flag {
align-self: center;
white-space: nowrap;
overflow: visible;
font-size: 0.66rem;
font-weight: 600;
line-height: 1;
color: var(--color-base-content);
z-index: 1;
}
.ms-dia {
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
}
.ms-unmet {
color: var(--color-error); /* unmet required features = a problem = red */
font-weight: 700;
}
.ms-lead {
justify-self: start;
width: 0;
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
pointer-events: none;
}
.hcell {
position: sticky;
top: 0;
z-index: 20;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-base-300);
padding: 0.4rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
.corner {
left: 0;
z-index: 30;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.wkhead {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.15;
padding-inline: 0.15rem;
}
.wklabel {
font-variant-numeric: tabular-nums;
}
.now-head {
color: var(--color-primary);
}
.badge-now {
font-size: 0.58rem;
font-weight: 700;
color: var(--color-primary);
}
.learnhead {
text-align: left;
}
.namecell {
position: sticky;
left: 0;
z-index: 10;
background: var(--color-base-100);
display: flex;
align-items: center;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.8rem;
font-weight: 500;
border-bottom: 1px solid var(--color-base-200);
}
.namecell .truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 2rem;
border-bottom: 1px solid var(--color-base-200);
}
.bar {
position: absolute;
top: 50%;
height: 2px;
transform: translateY(-50%);
background: currentColor;
opacity: 0.55;
}
.bar.full {
left: 0;
right: 0;
}
.bar.left {
left: 0;
right: 50%;
} /* enters from the left, stops at the glyph */
.bar.right {
left: 50%;
right: 0;
} /* starts at the first glyph, runs right */
.riser {
position: absolute;
left: 50%;
top: 28%;
bottom: 28%;
width: 2px;
transform: translateX(-50%);
background: currentColor;
opacity: 0.55;
}
.glyph {
position: relative;
z-index: 1;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.18rem;
/* halo so the bar reads as passing behind, not through, the symbol */
background: var(--color-base-100);
border-radius: 0.3rem;
}
.learncell {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 2rem;
padding: 0 0.6rem;
font-size: 0.72rem;
border-bottom: 1px solid var(--color-base-200);
}
.learncell .muted {
opacity: 0.5;
}
.learncell .note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 9999px;
flex: none;
}
/* "today" — a neutral grey column (red is reserved for problems) */
.cell.col-now,
.cell.col-now .glyph {
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
}
.col-ms {
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
}
.legend {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
font-size: 0.72rem;
opacity: 0.85;
}
.legend b {
font-weight: 700;
}
.now-swatch {
display: inline-block;
width: 0.8rem;
height: 0.8rem;
vertical-align: -0.1rem;
border-radius: 0.15rem;
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
}
</style>

View File

@@ -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<string, unknown>
try {
data = parseToml(source) as Record<string, unknown>
} 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<string>()
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<S extends v.GenericSchema>(
schema: S,
value: unknown,
where: string,
field?: string,
): v.InferOutput<S> {
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
}

View File

@@ -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
)
})
})

View File

@@ -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,
}
}

View File

@@ -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
}

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",
})
}