Compare commits

..

10 Commits

Author SHA1 Message Date
Julien Calixte
34219a6bdc fix: images now show full title
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 23s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-10 14:41:30 +01:00
Julien Calixte
fbfbbdc0be test(completion): cover bracket-header block separation
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m7s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-10 14:30:30 +01:00
Julien Calixte
b17de475ff fix(completion): separate a new block from the one above on accept
Accepting a bracketed [[…]] header typed directly under a block welded
the two together. When no blank line sits above, the insert now carries
a leading newline (never at the top of the document).
2026-07-10 14:30:30 +01:00
Julien Calixte
f7e856b082 test(editor): cover Enter-accepts and Escape newline semantics 2026-07-10 14:30:24 +01:00
Julien Calixte
8c14ae9c42 feat(editor): always accept completions on Enter
Enter now accepts the highlighted item like Tab whenever the popup is
open; dismiss with Escape first to insert a plain newline. Drops the
`engaged` gate that only accepted after navigating the menu.
2026-07-10 14:30:24 +01:00
Julien Calixte
f535c9ede5 feat(grid): widen the feature name column so longer names show 2026-07-10 14:30:13 +01:00
Julien Calixte
616a4c4301 docs(context): add the format-version term to the glossary
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-02 22:05:00 +02:00
Julien Calixte
83f04af8d7 test(format): cover version, uniqueness, and example conformance 2026-07-02 22:04:20 +02:00
Julien Calixte
53716a13ea feat(format): read and emit macroplan_version, require unique names
Reject a macroplan_version newer than this build understands rather than
silently mis-rendering it. Feature names are the join key for a
milestone's requires, so duplicates (previously resolved to the first
match) are now a parse error. The sample plan emits the version marker.
2026-07-02 22:04:15 +02:00
Julien Calixte
d0a34ef28a docs(format): define the portable Macroplan TOML format v1
Document the schema, value types, and render semantics so a Macroplan
.toml can be produced and consumed outside this app's renderer. Ships a
JSON Schema (served from public/ for editor use) and a reference file.
2026-07-02 22:04:06 +02:00
14 changed files with 488 additions and 35 deletions

View File

@@ -48,6 +48,10 @@ _Avoid_: today marker, cursor
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
_Avoid_: workspace, project, file list
**Format version**:
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
_Avoid_: schema version, file version, app version
## Symbols
- `┣` start of a Feature's bar

View File

@@ -30,9 +30,10 @@ Dashboard ┣━━━━━◯ 🔴 n
## Documentation
| Document | What it covers |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
| [docs/adr/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth |
| Document | What it covers |
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [docs/format.md](docs/format.md) | **The Macroplan TOML format (v1)** — the portable, tool-independent schema: fields, value types, the `macroplan_version` marker, and the render semantics a consumer must honor. Ships a [JSON Schema](public/macroplan.schema.json) and a [reference `.toml`](docs/macroplan.example.toml). |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
| [docs/adr/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth |

154
docs/format.md Normal file
View File

@@ -0,0 +1,154 @@
# Macroplan TOML Format — v1
The portable, tool-independent definition of a **Macroplan**: a week-by-week
delivery plan that records what a team promised, every time an estimate slipped,
and when work actually shipped — judged against the **first** estimate, never a
moved goalpost.
This document is the contract. Any tool can produce or consume a `.toml` file
that conforms to it and render the same plan. The [macroplan app](../README.md)
is one such consumer; nothing here is specific to it.
- **Machine-readable schema:** [`macroplan.schema.json`](../public/macroplan.schema.json),
served at `https://macroplan.apoena.dev/macroplan.schema.json` (JSON Schema
2020-12; validates the TOML-decoded structure).
- **Reference file:** [`macroplan.example.toml`](macroplan.example.toml)
(exercises every field and state; a conformance sample).
- **Serialization:** [TOML v1.0.0](https://toml.io). Values use TOML's native
types — dates are **bare date literals**, not quoted strings (see _Dates_).
## Versioning
A file MAY declare the format version at the top level:
```toml
macroplan_version = 1
```
- **Optional.** An absent marker means the current version (`1`). Every existing
file therefore stays valid.
- **Integer**, monotonically increasing. This document defines version **1**.
- A consumer that understands version _N_ **MUST reject** a file declaring a
version **greater than** _N_ (rather than silently mis-rendering it), and
SHOULD accept any version ≤ _N_ it still supports.
- The version is bumped only for a **breaking** change to the schema or render
semantics below — a new optional field does not require a bump.
Producers SHOULD emit `macroplan_version` so a file self-describes its target.
## Top-level fields
| Key | Required | Type | Default | Meaning |
| ------------------- | -------- | ------------- | ---------------------- | ------------------------------------------------------------------------------------- |
| `macroplan_version` | no | integer (`1`) | `1` | Format version (see above). |
| `title` | no | string | `"Untitled Macroplan"` | Display name of the plan. |
| `start` | no | date | auto-fit | Left edge of the plan's week span. **Only extends** the span; never clips a feature. |
| `end` | no | date | auto-fit | Right edge of the plan's week span. **Only extends** the span; never clips a feature. |
Zero or more `[[feature]]` blocks and zero or more `[[milestone]]` blocks follow.
## `[[feature]]`
A unit of work, read left-to-right as a story: where it started, what was first
promised, every slip, and when it shipped.
| Key | Required | Type | Default | Meaning |
| ------------- | -------- | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | **yes** | non-empty string | — | Identifies the feature; the **join key** for a milestone's `requires`. |
| `start` | **yes** | date | — | The week work began. |
| `original` | **yes** | date | — | The **Original Estimate** — the immovable baseline all lateness is judged against ([ADR-0001](adr/0001-original-estimate-as-baseline.md)). |
| `reestimates` | no | array of dates | `[]` | Each later week the estimate slipped to (a slip, `△`). Order-independent. |
| `delivered` | no | date | — | The week the feature actually shipped. Absent → still in flight. |
| `learning` | no | string | — | A post-delivery takeaway. |
| `status` | no | `"on-track"` \| `"at-risk"` \| `"off-track"` | — | In-flight confidence. Meaningful only while undelivered. |
| `note` | no | string | — | A short note accompanying `status`. |
**Constraint — unique names:** feature `name` values MUST be unique within a
plan. Because milestone `requires` resolves by name, a duplicate would make a
requirement ambiguous. Conforming consumers reject duplicates.
## `[[milestone]]`
A dated checkpoint that depends on a set of features being delivered by then.
| Key | Required | Type | Default | Meaning |
| ---------- | -------- | ---------------- | ------- | ------------------------------------------------------------------- |
| `name` | **yes** | non-empty string | — | Display name of the milestone. |
| `week` | **yes** | date | — | When the milestone falls due. |
| `requires` | no | array of strings | `[]` | Feature `name`s that must be delivered on/before `week` to meet it. |
A `requires` entry that names no existing feature counts as **unmet** (it can
never be satisfied) — useful for catching typos.
## Value types
### Dates → weeks
Every date field is a **calendar date**, written as a TOML bare date literal:
```toml
original = 2026-06-15
reestimates = [2026-06-29, 2026-07-13]
```
- **Canonical form is the bare date literal.** A quoted `"2026-06-15"` string is
_tolerated_ on input for convenience, but producers SHOULD emit bare literals
so the same plan always serializes identically.
- **Any day snaps to its ISO-week Monday.** A Macroplan's unit is the week, keyed
by the Monday (yyyy-mm-dd) of the ISO week containing the date. `2026-06-17`
(a Wednesday) and `2026-06-15` (that Monday) denote the **same week**. Every
conforming renderer MUST apply this snapping, or dates land in the wrong column.
### Status
The enum `on-track` \| `at-risk` \| `off-track`. No other value is valid. Lateness
is **derived, never authored** — there is deliberately no "late" status.
## Render semantics (the contract a renderer must honor)
Two tools should draw the same plan from the same file. These rules are as much
a part of the format as the fields:
1. **On-time vs. late is judged only against `original`** — never a re-estimate
([ADR-0001](adr/0001-original-estimate-as-baseline.md)). `delivered ≤ original`
(by week) is on time; `delivered > original` is late.
2. **Marker vocabulary** per feature:
- `◯` **original** — the Original Estimate week, while undelivered.
- `△` **re-estimate** — one per `reestimates` entry.
- `◉` **delivered on time** — a delivery on/before `original`. It **subsumes**
the `◯` (no separate original marker is drawn).
- `▲` **delivered late** — a delivery after `original`; the `◯` baseline is
**kept** alongside it.
3. **Bar extent.** A feature's bar runs from `start` to its furthest marker. A
delivered bar ends at its delivery. An **overdue** feature (undelivered, and
already past its furthest estimate relative to "now") keeps running to the
current week.
4. **Plan span.** The rendered week range runs from the earliest to the latest
week among all features' markers and all milestones. Optional `start`/`end`
only widen this range with lead-in / trailing weeks; a marker outside them is
never clipped.
5. **Milestone met/unmet.** A required feature is **unmet** at a milestone if it
is undelivered, delivered _after_ the milestone `week`, or names no existing
feature.
6. **"Now".** The current week (Monday of today) drives overdue extension and the
"now" line. It is contextual, not stored in the file.
## Notes for implementers
- **Unknown keys.** The JSON Schema is strict (`additionalProperties: false`) so
editors flag typos like `titel`. The reference parser is more lenient — it
ignores unrecognized top-level keys. Producers SHOULD NOT emit unknown keys.
- **Empty plan.** A file with no features and no milestones is valid; with an
authored `start`/`end` it renders as an empty grid across that span.
- **Editor integration.** Point [Taplo](https://taplo.tamasfe.dev)-based editors
at the schema with a directive on the first line of a plan file:
`#:schema https://macroplan.apoena.dev/macroplan.schema.json`.
## See also
- [CONTEXT.md](../CONTEXT.md) — the ubiquitous language (Feature, Original
Estimate, Re-estimate, Delivery, Milestone, Week, Status, Learning).
- [ADR-0001](adr/0001-original-estimate-as-baseline.md) — why lateness is judged
against the Original Estimate.
- [ADR-0002](adr/0002-local-first-no-backend.md) — why the `.toml` file is the
portable source of truth.

View File

@@ -0,0 +1,79 @@
#:schema https://macroplan.apoena.dev/macroplan.schema.json
#
# Reference Macroplan — a conformance sample exercising every field and state.
# The authoritative definition of this format is docs/format.md.
# Format version. Optional; the current version is 1. Omitting it means "1".
macroplan_version = 1
title = "Q3 — Checkout revamp"
# Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: `start` only ever extends the span left, `end` extends it right;
# neither clips a feature. Omit both to auto-fit the columns.
start = 2026-05-25
end = 2026-08-03
# Dates are TOML date literals. Any day snaps to that week's Monday.
# On-time delivery: delivered on/before the Original Estimate → ◉ (subsumes ◯).
[[feature]]
name = "Auth"
start = 2026-06-01
original = 2026-06-15
delivered = 2026-06-15
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
# Late delivery with slips: ◯ baseline kept, one △ per re-estimate, then ▲.
# Late because delivery (07-20) is after `original` (06-15) — the re-estimates
# never move the goalpost (ADR-0001).
[[feature]]
name = "Payments"
start = 2026-06-01
original = 2026-06-15
reestimates = [2026-06-29, 2026-07-13]
delivered = 2026-07-20
learning = "Vendor lead time was the real constraint — derisk vendors up front."
# In-flight and overdue: undelivered, `original` already in the past → ◯ only,
# bar runs to "now". `status` + `note` express confidence while it's open.
[[feature]]
name = "Dashboard"
start = 2026-06-01
original = 2026-06-08
status = "off-track"
note = "No recovery plan yet — needs an owner."
# In-flight, re-estimated once: ◯ + △, still open.
[[feature]]
name = "Search"
start = 2026-06-08
original = 2026-06-22
reestimates = [2026-07-06]
status = "at-risk"
note = "Third-party search API is flaky; spike a fallback."
# In-flight, on track: the minimal feature — only the three required fields
# plus a status.
[[feature]]
name = "Notifications"
start = 2026-06-22
original = 2026-07-06
status = "on-track"
# Milestones tie a due week to the features that must ship by then.
[[milestone]]
name = "Code freeze"
week = 2026-06-29
requires = ["Auth", "Payments"]
[[milestone]]
name = "MVP go-live"
week = 2026-07-06
requires = ["Auth", "Payments", "Dashboard"]
# A milestone with no requirements is valid too.
[[milestone]]
name = "Beta launch"
week = 2026-07-13
requires = ["Search", "Notifications"]

View File

@@ -0,0 +1,108 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://macroplan.apoena.dev/macroplan.schema.json",
"title": "Macroplan",
"description": "A week-by-week delivery plan. Portable TOML format v1 — see docs/format.md. Validates the TOML-decoded structure; dates are TOML date literals, surfaced to JSON Schema as date-formatted strings.",
"type": "object",
"additionalProperties": false,
"properties": {
"macroplan_version": {
"description": "Format version. Omit for the current version (1). A newer version must be rejected by a consumer that only understands 1.",
"type": "integer",
"const": 1
},
"title": {
"description": "Display name of the plan. Defaults to \"Untitled Macroplan\" when absent.",
"type": "string"
},
"start": {
"description": "Optional left edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"end": {
"description": "Optional right edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"feature": {
"description": "The features of the plan. Written as [[feature]] blocks. Names must be unique.",
"type": "array",
"items": { "$ref": "#/$defs/feature" }
},
"milestone": {
"description": "Dated checkpoints. Written as [[milestone]] blocks.",
"type": "array",
"items": { "$ref": "#/$defs/milestone" }
}
},
"$defs": {
"date": {
"description": "A calendar date (TOML date literal, e.g. 2026-06-15). Snapped to its ISO-week Monday.",
"type": "string",
"format": "date"
},
"feature": {
"type": "object",
"additionalProperties": false,
"required": ["name", "start", "original"],
"properties": {
"name": {
"description": "Identifies the feature; the join key for a milestone's `requires`. Must be unique within the plan.",
"type": "string",
"minLength": 1
},
"start": {
"description": "The week work began.",
"$ref": "#/$defs/date"
},
"original": {
"description": "The Original Estimate — the immovable baseline all lateness is judged against (ADR-0001).",
"$ref": "#/$defs/date"
},
"reestimates": {
"description": "Each later week the estimate slipped to. Order-independent.",
"type": "array",
"items": { "$ref": "#/$defs/date" }
},
"delivered": {
"description": "The week the feature actually shipped. Absent means still in flight.",
"$ref": "#/$defs/date"
},
"learning": {
"description": "A post-delivery takeaway.",
"type": "string"
},
"status": {
"description": "In-flight confidence. Meaningful only while undelivered. Lateness is derived, never authored.",
"enum": ["on-track", "at-risk", "off-track"]
},
"note": {
"description": "A short note accompanying `status`.",
"type": "string"
}
}
},
"milestone": {
"type": "object",
"additionalProperties": false,
"required": ["name", "week"],
"properties": {
"name": {
"description": "Display name of the milestone.",
"type": "string",
"minLength": 1
},
"week": {
"description": "When the milestone falls due.",
"$ref": "#/$defs/date"
},
"requires": {
"description": "Feature names that must be delivered on/before `week` to meet the milestone.",
"type": "array",
"items": { "type": "string" }
}
}
}
}
}

View File

@@ -179,4 +179,12 @@ function confirmDelete() {
.export-root.exporting :deep(.macroplan) {
overflow: visible;
}
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
status notes) must wrap to its full length in the capture instead of being cut.
The column width is unchanged, so the milestone band stays aligned. */
.export-root.exporting :deep(.namecell .truncate),
.export-root.exporting :deep(.learncell .note) {
white-space: normal;
overflow: visible;
}
</style>

View File

@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
}
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16
const NAME_W = 16 * 16
const WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
<style scoped>
.macroplan {
--name-w: 9rem;
--name-w: 16rem;
--wk: 3.5rem;
}
.plan-grid {

View File

@@ -54,20 +54,21 @@ describe("PlanEditor completion keys", () => {
expect(wrapper.find(".completion").exists()).toBe(false)
})
it("Enter does not accept an untouched popup — it closes instead", async () => {
it("Enter accepts the selected item without navigating first", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
// No accept happened: the last emit is still the raw typed value.
expect(lastEmit(wrapper)).toBe("t")
expect(lastEmit(wrapper)).toBe("title = ")
expect(wrapper.find(".completion").exists()).toBe(false)
})
it("Enter accepts once the author has navigated the popup", async () => {
it("Escape dismisses the popup so the next Enter is a newline, not an accept", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "ArrowDown" })
await wrapper.find("textarea").trigger("keydown", { key: "Escape" })
expect(wrapper.find(".completion").exists()).toBe(false)
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
expect(lastEmit(wrapper)).toBe("title = ")
// Popup is gone, so Enter accepts nothing — the value stays as typed.
expect(lastEmit(wrapper)).toBe("t")
})
})

View File

@@ -19,10 +19,6 @@ const popup = ref({ left: 0, top: 0 })
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
// back open while the author is still typing the word they just dismissed.
let justEscaped = false
// Whether the author has moved the selection since the popup (re)opened. Tab
// always accepts; Enter only accepts once they've engaged with the menu — an
// untouched popup lets Enter do its normal job (a newline) rather than hijack it.
let engaged = false
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
@@ -64,7 +60,6 @@ function refresh() {
if (!el) return
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
selected.value = 0
engaged = false
if (completion.value) position()
}
@@ -125,7 +120,6 @@ function onKeydown(e: KeyboardEvent) {
if (!ctx) return
const move = (delta: number) => {
e.preventDefault()
engaged = true
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
}
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
@@ -140,20 +134,13 @@ function onKeydown(e: KeyboardEvent) {
case "ArrowUp":
move(-1)
break
case "Enter":
case "Tab":
// Both accept the highlighted item whenever the popup is open. To insert a
// newline instead, dismiss the popup with Escape first, then press Enter.
e.preventDefault()
accept(selected.value)
break
case "Enter":
// Only steal Enter once the author has navigated the menu; otherwise let
// it insert a newline (onInput → refresh reopens the popup if warranted).
if (engaged) {
e.preventDefault()
accept(selected.value)
} else {
completion.value = null
}
break
case "Escape":
e.preventDefault()
completion.value = null

View File

@@ -157,3 +157,25 @@ describe("completion replace range", () => {
expect(ctx.items[0].insert).toBe("[[feature]]")
})
})
describe("bracket header block separation", () => {
/** The inserts offered at the `|` marker. */
const insertsAt = (marked: string) => {
const caret = marked.indexOf("|")
const source = marked.slice(0, caret) + marked.slice(caret + 1)
return getCompletions(source, caret)!.items.map((i) => i.insert)
}
it("prepends a blank line when a bare [ glues onto the block above", () => {
// No blank line above ⇒ accepting must not weld the new block to the old one.
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
})
it("adds no leading newline when a blank line already separates the block", () => {
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
})
it("adds no leading newline at the very top of the document", () => {
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
})
})

View File

@@ -103,7 +103,7 @@ export function getCompletions(
if (key) {
const [, indent, brackets, word] = key
if (brackets) {
return result(lineStart + indent.length, caret, filter(HEADERS, word))
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
}
const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
@@ -128,6 +128,15 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : null
}
/** Header completions for a bare `[`. When the block above isn't already set off
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
* never glues the new block onto the previous one. (At the top of the document
* there's nothing to separate, so no gap is added.) */
function headerItems(source: string, lineStart: number): Completion[] {
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
}
/** Is the line directly above the caret's line blank (empty or whitespace only)?
* Gates header suggestions to the "two new lines" rule — see the key branch. */
function blankLineAbove(source: string, lineStart: number): boolean {

View File

@@ -1,7 +1,10 @@
// Default Macroplan shown on first load — exercises every state:
// on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track),
// an overdue Feature, and a Milestone with unmet required Features.
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md.
macroplan_version = 1
title = "Q3 — Checkout revamp"
# Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.

View File

@@ -45,6 +45,11 @@ const MilestoneSchema = v.object({
),
})
/** 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>
@@ -54,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan {
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: asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
),
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)) {

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"
import exampleToml from "../../docs/macroplan.example.toml?raw"
import { parseMacroplan, PlanParseError } from "./parse"
import { buildPlan } from "./plan"
import { mondayOf, weekRange } from "./week"
@@ -181,3 +182,42 @@ describe("parse validation", () => {
expect(raw.milestones).toHaveLength(3)
})
})
describe("format version (macroplan_version)", () => {
const feature = '[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n'
it("accepts an explicit current version", () => {
expect(() => parseMacroplan(`macroplan_version = 1\n${feature}`)).not.toThrow()
})
it("accepts an absent version (defaults to current)", () => {
expect(() => parseMacroplan(feature)).not.toThrow()
})
it("rejects a future/unsupported version rather than mis-rendering it", () => {
expect(() => parseMacroplan(`macroplan_version = 2\n${feature}`)).toThrow(/unsupported/)
})
it("rejects a non-integer version", () => {
expect(() => parseMacroplan(`macroplan_version = 1.5\n${feature}`)).toThrow(/macroplan_version/)
})
})
describe("feature-name uniqueness", () => {
it("rejects duplicate feature names (they are the milestone join key)", () => {
const dup =
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n' +
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
expect(() => parseMacroplan(dup)).toThrow(/duplicate/)
expect(() => parseMacroplan(dup)).toThrow(PlanParseError)
})
})
describe("reference example (docs/macroplan.example.toml)", () => {
it("conforms to the format the parser implements", () => {
const raw = parseMacroplan(exampleToml)
expect(raw.title).toBe("Q3 — Checkout revamp")
expect(raw.features).toHaveLength(5)
expect(raw.milestones).toHaveLength(3)
})
})