Compare commits

...

50 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
Julien Calixte
e380e6d336 test(completion): cover popup viewport fitting geometry
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 24s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-07-02 21:45:41 +02:00
Julien Calixte
9ee7c05019 fix(completion): keep the popup inside the window near screen edges
Anchoring the popup below the caret let it spill off the right/bottom of
the window when the caret sat at the edge, so the browser clipped it.
After render, measure the popup and shift it left off the right edge or
flip it above the caret line when there's no room below.
2026-07-02 21:45:35 +02:00
Julien Calixte
16b07844a8 test(editor): cover Tab/Enter completion accept semantics
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 44s
Deploy to GitHub Pages / deploy (push) Has been skipped
Mounts PlanEditor under happy-dom and drives the popup: Tab accepts an
untouched menu, Enter closes it without accepting, and Enter accepts once
the selection has been navigated.
2026-07-02 09:39:37 +02:00
Julien Calixte
f20935a85b style(design): drop a stray blank line before the performance budget
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
2026-07-02 09:37:08 +02:00
Julien Calixte
96c3f489a3 feat(editor): accept completions on Enter only after navigating
Enter kept hijacking the auto-opened popup mid-typing. Now Tab always
accepts, while Enter accepts only once the author has moved the selection
(arrow / Ctrl-N/P); an untouched popup lets Enter insert a newline.
2026-07-02 09:37:02 +02:00
Julien Calixte
449be7766e fix(completion): gate block headers behind a blank line
The popup offered [[feature]]/[[milestone]] on every fresh line — right
under the last property or straight after a header. Only volunteer them
once there's a blank line above (the "two new lines" convention); typing
a bare `[` still summons them anywhere. Property keys are unchanged.
2026-07-02 09:36:53 +02:00
Julien Calixte
db77314a88 Merge jcalixte/main into main
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
Reconcile the divergent house-of-quality work. Conflict in DESIGN.md
was the preamble's default \qfdHdrH (2.6 from the canonical resync vs
3.5 from the incoming label-overflow fix): keep the preamble at the
canonical 2.6 and move the 3.5 sizing override into the Macroplan
house block where the other house-specific dims live. Same render.
2026-06-25 11:45:31 +01:00
Julien Calixte
c72a95d942 docs(design): title the House of Quality with name and concept
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m8s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-25 11:42:11 +01:00
Julien Calixte
e392eca51b docs(design): resync House of Quality preamble to canonical reference
The embedded preamble had drifted from the qfd skill's reference: it
lacked the title-block machinery, section comments, and sizing notes.
Replace it verbatim so future edits don't fight stale scaffolding.
2026-06-25 11:41:50 +01:00
Julien Calixte
e9c9f04d68 docs(design): move house of quality before goals, tighten F1 target
House now leads the document as the quick-reference overview; numbered
sections follow as the prose backing. \qfdHdrH raised 2.6 → 3.5 so F2/F3
labels no longer overflow the roof. F1 legibility target tightened to ≤1s
throughout (§2 table, §3 cascade, tikz basement, §7 budget).
2026-06-21 12:52:39 +02:00
Julien Calixte
122147c19c refactor(grid): label the name column header instead of repeating the title
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
The corner header rendered plan.title in the 9rem name column, so it
collapsed to "..." — and the title already shows in the switcher, the
source, and the heading above the grid. Label its own column instead.
2026-06-19 19:03:56 +02:00
Julien Calixte
b6f7bcab82 fix(editor): pin line-height to integer px so the caret stays aligned
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 23s
Deploy to GitHub Pages / deploy (push) Has been skipped
A fractional line box (0.8rem × 1.6) is rounded differently by the
textarea than by the <pre> backdrop, so the caret drifted further from
its glyph the lower it sat — worst at the bottom of a tall file.
2026-06-19 18:55:42 +02:00
Julien Calixte
be86b82823 fix(completion): stop offering status once its quote is closed
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 23s
Deploy to GitHub Pages / deploy (push) Has been skipped
The pattern's trailing optional quote let a complete `status = "x"`
match, so the popup reopened after the closing quote. Drop it so the
match ends with the value, while still typing remains supported.
2026-06-19 18:54:37 +02:00
Julien Calixte
e726675d77 fix(editor): don't open the completion popup on deletion
Backspace/delete/cut emit an input event too, which reopened the
popup mid-edit. Skip the refresh on any delete inputType.
2026-06-19 18:53:55 +02:00
Julien Calixte
5a4fbb452d fix(editor): close completion popup after accepting a value
Accepting an enum value (e.g. "on-track") re-ran the chained refresh,
which re-matched the value and reopened the popup. Chain only after
completing a key, when there are still values to offer.
2026-06-19 18:53:44 +02:00
Julien Calixte
d26ebeef71 fix(completion): use local date for the "today" suggestion
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
Slicing the UTC ISO string showed the wrong calendar day near
midnight for non-UTC authors. Build it from local getFullYear/
getMonth/getDate so it matches the day the author actually sees.
2026-06-19 18:45:58 +02:00
Julien Calixte
e42987a070 feat(completion): suggest dates in date value positions
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 22s
Deploy to GitHub Pages / deploy (push) Has been skipped
The TOML editor offered nothing when typing a date, leaving the
yyyy-mm-dd format undiscoverable until a parse error. Suggest today
(a full, easy-to-edit date) and the current-year prefix on empty
date values and fresh reestimates elements, backing off once a date
is being typed.
2026-06-19 18:43:41 +02:00
Julien Calixte
289dde523f docs(ci): fix stale base-path comment in Pages workflow
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 24s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-19 18:25:43 +02:00
Julien Calixte
d2f2c949c9 chore(ci): add GitHub Pages build and deploy workflow
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 23s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-19 18:17:49 +02:00
Julien Calixte
65726a0211 refactor(layout): rename view-only mode to "macroplan"
Avoids the redundant ?view=view in favour of the explicit ?view=macroplan,
matching the button label.
2026-06-19 17:48:30 +02:00
Julien Calixte
f42cb2b779 feat(layout): toggle between source, macroplan, and split panes
The chosen layout is kept in the URL (?view=) so it's shareable and
bookmarkable rather than persisted. PNG export is disabled in
source-only mode since the rendered node it captures isn't mounted then.
2026-06-19 17:42:38 +02:00
Julien Calixte
120a329421 chore: format 2026-06-19 17:30:34 +02:00
Julien Calixte
0ac212a4ac chore: add oxfm and oxlint 2026-06-19 17:30:05 +02:00
Julien Calixte
abce8b65cd style(editor): widen completion popup to avoid horizontal scroll 2026-06-19 10:39:55 +02:00
Julien Calixte
3d7175006e feat(editor): navigate completions with Ctrl+N/Ctrl+P 2026-06-19 10:39:49 +02:00
Julien Calixte
4a8395abb6 feat(editor): show a completion popup as you type
Anchor a suggestion list at the caret (monospace metrics, re-anchored on
scroll), with arrow/Enter/Tab/Esc handling and click-to-accept. Accepting
a key like `status` chains straight into its value suggestions.
2026-06-18 18:35:30 +02:00
Julien Calixte
7245211b62 feat(editor): add schema-aware TOML completion logic
Given a source and caret offset, suggest block headers, the keys valid
for the current block (excluding ones already written), status enum
values, and the feature names referenced by a milestone's requires.
Mirrors the schema in parse.ts.
2026-06-18 18:35:25 +02:00
Julien Calixte
40ee9f509c refactor(model): name Feature status by domain, not RAG color
StatusLevel held green/orange/red — the RAG vocabulary CONTEXT.md
explicitly lists under _Avoid_ for the Status term, leaking a
presentation concern into the model and authored TOML. Replace with
on-track/at-risk/off-track; color now lives only in the renderer's
tone() mapping. Breaking change to authored .toml status values.
2026-06-18 18:13:53 +02:00
Julien Calixte
64efc91392 feat: add valibot 2026-06-17 10:20:40 +02:00
Julien Calixte
362a448848 docs(model): clarify the switchTo last-good reset comment 2026-06-17 09:58:03 +02:00
Julien Calixte
e2f76195dd fix(ui): disable the delete button during PNG export 2026-06-17 09:58:03 +02:00
Julien Calixte
69932fa2bf docs: record the Library term and mark the plans library as built 2026-06-17 09:53:09 +02:00
Julien Calixte
ba5442c58f test(app): cover the switcher, delete-confirm and .toml download wiring 2026-06-17 09:51:20 +02:00
Julien Calixte
7468ebd3c9 feat(ui): wire the plan switcher, delete confirm and .toml download into the app 2026-06-17 09:46:56 +02:00
Julien Calixte
3a012f186d feat(model): make useMacroplan own a library of named plans 2026-06-17 09:40:40 +02:00
Julien Calixte
1c986e417f feat(ui): add the plan switcher dropdown 2026-06-17 09:35:32 +02:00
Julien Calixte
3c32542a76 feat(export): add .toml source download and shared slugify helper 2026-06-17 09:32:07 +02:00
Julien Calixte
35c84cf943 docs: reword the Macroplan definition to be less flat
Lead with the promise-vs-reality tension ("keeps the receipts")
instead of the limp "learning-oriented record of what we committed
to deliver".
2026-06-17 08:50:52 +02:00
Julien Calixte
b876bca083 docs(plan): break down the plans library implementation 2026-06-17 08:50:00 +02:00
Julien Calixte
c4765d9630 docs(spec): design the plans library 2026-06-17 08:38:33 +02:00
42 changed files with 3911 additions and 489 deletions

53
.github/workflows/deploy-pages.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# Allow the deploy job to publish to Pages via OIDC.
permissions:
contents: read
pages: write
id-token: write
# Only one Pages deployment at a time; cancel superseded runs.
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# Served from a custom domain at the root, so no --base override.
- run: pnpm build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

6
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": false,
"ignorePatterns": []
}

11
.oxlintrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "oxc"],
"env": {
"browser": true,
"es2024": true
},
"categories": {
"correctness": "error"
}
}

40
.zed/settings.json Normal file
View File

@@ -0,0 +1,40 @@
{
"languages": {
"TypeScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"TSX": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"JavaScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"Vue.js": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
}
}
}

View File

@@ -1,11 +1,11 @@
# Macroplan # Macroplan
A **Macroplan** is a week-granular view of the features we've committed to deliver — a simpler cousin of a Gantt chart whose purpose is not just scheduling but *learning* from how our estimates held up over time. A **Macroplan** is a week-by-week plan that keeps the receipts: what we promised to deliver, when it actually landed, and what the gap taught us — a simpler cousin of a Gantt chart whose purpose is not just scheduling but _learning_.
## Language ## Language
**Macroplan**: **Macroplan**:
A week-granular plan of the **Features** we've committed to deliver, read as a record of how estimates held against reality. A week-by-week plan that keeps the receipts on the **Features** we promised: when each actually landed, and what the gap taught us.
_Avoid_: Gantt, roadmap, timeline _Avoid_: Gantt, roadmap, timeline
**Feature**: **Feature**:
@@ -33,7 +33,7 @@ A free-text takeaway captured against a Feature once it is delivered — what th
_Avoid_: retro note, lesson, postmortem _Avoid_: retro note, lesson, postmortem
**Status**: **Status**:
A Feature's *current* delivery confidence (a snapshot, overwritten each review): **green** (all good), **orange** (in trouble but we have a plan), **red** (in trouble and we have no plan). May carry a comment. Applies only while in-flight; once delivered, the **Learning** takes over and the Status is dropped. An overdue Feature (past its latest estimate, not delivered) is expressed through an orange/red Status, not a dedicated symbol. A Feature's _current_ delivery confidence (a snapshot, overwritten each review): **on-track** (all good), **at-risk** (in trouble but we have a plan), **off-track** (in trouble and we have no plan). May carry a comment. Applies only while in-flight; once delivered, the **Learning** takes over and the Status is dropped. An overdue Feature (past its latest estimate, not delivered) is expressed through an at-risk/off-track Status, not a dedicated symbol.
_Avoid_: health, RAG, risk _Avoid_: health, RAG, risk
**Week**: **Week**:
@@ -44,6 +44,14 @@ _Avoid_: column, period, sprint
A vertical line marking the current week across the whole plan — the at-a-glance "where are we right now". A vertical line marking the current week across the whole plan — the at-a-glance "where are we right now".
_Avoid_: today marker, cursor _Avoid_: today marker, cursor
**Library**:
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 ## Symbols
- `┣` start of a Feature's bar - `┣` start of a Feature's bar
@@ -56,6 +64,7 @@ _Avoid_: today marker, cursor
## Relationships ## Relationships
- A **Macroplan** contains a flat, author-ordered list of **Features** (typically ordered by start **Week**) and many **Milestones**. There is no grouping/workstream concept. - A **Macroplan** contains a flat, author-ordered list of **Features** (typically ordered by start **Week**) and many **Milestones**. There is no grouping/workstream concept.
- The **Library** holds many **Macroplans**, exactly one of which is active (shown in the editor and grid). Each is identified internally by a stable id and labelled by its **title**.
- A **Feature** has exactly one **Original Estimate**, zero or more **Re-estimates**, at most one **Delivery**, and at most one **Learning**. - A **Feature** has exactly one **Original Estimate**, zero or more **Re-estimates**, at most one **Delivery**, and at most one **Learning**.
- A **Milestone** explicitly names the **Features** required by it; a Feature may be required by zero, one, or several Milestones, and a Feature may be in the plan without belonging to any Milestone. - A **Milestone** explicitly names the **Features** required by it; a Feature may be required by zero, one, or several Milestones, and a Feature may be in the plan without belonging to any Milestone.
- On-time vs. late is judged against the **Original Estimate**, never a **Re-estimate**. - On-time vs. late is judged against the **Original Estimate**, never a **Re-estimate**.

330
DESIGN.md
View File

@@ -1,133 +1,37 @@
# Macroplan — Design (QFD) # Macroplan — Design (QFD)
Goal-driven design for the Macroplan app: a week-granular, learning-oriented view of committed Features rendered as an interactive web view from a source file. This document covers *what the system must do and what we build*; the vocabulary lives in [CONTEXT.md](CONTEXT.md) and the original-estimate baseline decision in [ADR-0001](docs/adr/0001-original-estimate-as-baseline.md). It is not a spec or a task list. Goal-driven design for the Macroplan app: a week-granular, learning-oriented view of committed Features rendered as an interactive web view from a source file. This document covers _what the system must do and what we build_; the vocabulary lives in [CONTEXT.md](CONTEXT.md) and the original-estimate baseline decision in [ADR-0001](docs/adr/0001-original-estimate-as-baseline.md). It is not a spec or a task list.
Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none. Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none.
--- ---
## 1. Goals — the WHATs ## House of Quality
| ID | Goal | Weight | Source |
|-----|-------------------------------------------------------------------------------|:------:|---------------------------------|
| G1 | See at a glance where every Feature stands against its Original Estimate — the honest record | 10 | brief + [CONTEXT.md](CONTEXT.md) |
| G2 | Turn estimation misses into captured Learnings for next time | 8 | brief + [CONTEXT.md](CONTEXT.md) |
| G4 | Author & update the whole plan fast during a weekly review | 8 | brief |
| G3 | Know whether external Milestones are at risk | 7 | brief + [CONTEXT.md](CONTEXT.md) |
| G5 | Hand stakeholders a shareable read-only view | 5 | brief |
## 2. Functions — the HOWs
| ID | Function | Dir | Target (now) |
|-----|----------------------------------------------------------------------|:---:|-------------------------------------------------------------------------------------------------------|
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤3s; Feature name + week axis never lost even when the plan exceeds the viewport |
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
| F5 | Make it easy to attach an optional Learning to a delivered Feature | ↓ | one optional field; rendered in the trailing column when present, blank otherwise |
| F6 | Render a Milestone and flag its unmet required Features | → | vertical line at the correct Week; unmet required Features identifiable |
| F7 | Export the rendered plan as a shareable image (clipboard + download) | → | one click → PNG on clipboard and/or downloaded; fully client-side, no backend |
## 3. Cascade — Goals → Functions → How → Components
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤3s; name + axis never lost_
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary *inside* cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
- **G2** Turn estimation misses into captured Learnings _W:8_
- **F5** Make it easy to attach an optional Learning to a delivered Feature _Dir↓ one optional field_
- **How**: a single optional `learning` field on a Feature block; rendered in a persistent trailing column when present
- **Component**: C3 (trailing Learning column), C1 (TOML field)
- **G4** Author & update the whole plan fast during a weekly review _W:8_
- **F4** Add / edit / remove a Feature with a single local edit _Dir↓ one block per op, no ripple_
- **How**: TOML `[[feature]]` / `[[milestone]]` blocks keyed by date literals; renderer derives Weeks & marker placement so edits never ripple. See T2.
- **Component**: **C1 TOML source + parser** (smol-toml) → Plan model
- **F3** Reflect a source edit in the view _Dir↓ ≤1s_
- **How**: in-app split editor, re-parse on every keystroke (instant); autosave to localStorage. Chosen over Vite-HMR-on-file (needs a running toolchain) and load-file-only (slow loop). See T3.
- **Component**: **C4 Split editor** (editor pane + Vue reactivity)
- **G3** Know whether external Milestones are at risk _W:7_
- **F6** Render a Milestone and flag its unmet required Features _Dir→ correct Week; unmet identifiable_
- **How**: `[[milestone]]` references Features by name; renderer draws a vertical line at the milestone Week and marks which required Features are undelivered
- **Component**: C2 (membership + unmet computation), C3 (vertical line overlay)
- **G5** Hand stakeholders a shareable read-only view _W:5_
- **F7** Export the rendered plan as a shareable image _Dir→ one click → PNG_
- **How**: client-side DOM-to-PNG (html-to-image) → clipboard + download. Chosen over hosting a URL, which has no data unless the source is also shipped (local-first tool). See T1, T4.
- **Component**: **C6 Image exporter**
- **F2** Classify each Delivery on-time/late vs the Original Estimate _Dir→ 100% correct per ADR-0001_ (serves G1)
- **How**: pure derivation in the Plan model — compare Delivery Week to the Original Estimate Week; never to a Re-estimate
- **Component**: **C2 Plan model** — derives contiguous Monday Weeks, classifies markers, computes Milestone membership
### Components
| ID | Component | Realises | ADR |
|----|-----------|----------|-----|
| C1 | TOML source + parser (smol-toml) → Plan model | F4, feeds F2 | ADR-0002 |
| C2 | Plan model — Week derivation, on-time/late classification, Milestone membership | F2, F6 | ADR-0001 |
| C3 | Grid renderer (Vue + CSS Grid) — bars, symbols, colors, Now line, sticky panes, hover, trailing Learning column | F1, F5, F6 | — |
| C4 | Split editor — parse-on-keystroke, localStorage autosave | F3 | ADR-0002 |
| C5 | Plan library — named Macroplans in localStorage, switch/CRUD, Import/Export .toml | scope, persistence | ADR-0002 |
| C6 | Image exporter (html-to-image) — PNG to clipboard + download | F7 | — |
## 4. House — Goals × Functions
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Σ = `Σ(weight × strength)`.
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|------------|:---:|:---:|:--:|:--:|:--:|:--:|:--:|
| G1 (10) | 9 | 9 | 1 | 1 | | 1 | |
| G2 (8) | 3 | | | | 9 | | |
| G4 (8) | 1 | | 9 | 9 | 3 | 1 | |
| G3 (7) | 3 | 3 | | | | 9 | |
| G5 (5) | 3 | | | | | | 9 |
| **Σ** | 158 | 111 | 82 | 82 | 96 | 81 | 45 |
| **Rel %** | 24 | 17 | 13 | 13 | 15 | 12 | 7 |
**Top engineering priorities:** **F1 (render, 24%)** and **F2 (classify, 17%)** carry the most goal-value — together they *are* G1, the anchor goal, so the grid renderer and the Plan-model classifier deserve the most care. **F5 (15%)** ranks third despite a single goal because it is the *sole* driver of G2 (weight 8) — under-investing in frictionless Learning capture silently abandons the learning goal. The authoring pair **F3/F4 (13% each)** matter as a unit. **F7 (7%)** is genuinely a nice-to-have; keep it cheap. (Note: this importance lens differs from §7's risk lens, where F2 ranks first as the hard correctness gate.)
## 5. Roof — Function × Function tradeoffs
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| **F1** | — | ○ | × | | | | × |
| **F2** | | — | | ○ | | | |
| **F3** | | | — | ◎ | | | |
| **F4** | | | | — | | | |
| **F5** | | | | | — | | |
| **F6** | | | | | | — | |
| **F7** | | | | | | | — |
**Conflicts that actually shape the design:**
- **F1 × F7 (×).** The richer F1's *hover-only* content, the more an exported image (F7) loses. Mitigation: keep the status *color* always-visible; only the note is hover-only. Owned by tension in §8.
- **F1 × F3 (×).** A heavier render (sticky panes, many cells) can slow the ≤1s reflect loop. Mitigation in §7: debounce / parse only changed blocks.
- **F3 ◎ F4.** Instant reflect + local ripple-free edits reinforce strongly — together they *are* fast authoring (G4). Invest in them as a pair.
- **F2 ○ F4.** TOML's explicit date literals feed reliable classification — structured source *helps* correctness (this is why the earlier "terse-vs-robust" tension dissolved once we chose TOML over a DSL).
## 6. Function → Component map
Strength of each Component in realising each Function (9/3/1/blank). Component list and ADR anchors are in §3.
| | C1 | C2 | C3 | C4 | C5 | C6 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
| **F1** | | 3 | 9 | | | |
| **F2** | 3 | 9 | | | | |
| **F3** | 3 | | | 9 | | |
| **F4** | 9 | | | 3 | | |
| **F5** | 3 | | 9 | | | |
| **F6** | | 9 | 3 | | | |
| **F7** | | | 3 | | | 9 |
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
### House of Quality (rendered)
```tikz ```tikz
% ===================================================================== % =====================================================================
% QFD "House of Quality" preamble % QFD "House of Quality" preamble
% ===================================================================== % =====================================================================
% Usage:
% \begin{document}
% \begin{qfdhouse}
% % WHATs at ({\qfdLeftEdge + 0.1}, {-\r + 0.5})
% % Importance at ({-\qfdImpW/2}, {-\r + 0.5})
% % HOWs rotated 90 at ({\c - 0.5}, 0.15)
% % Relations in cells with [qfdrel/S], [qfdrel/M], [qfdrel/W]
% % Roof correlations at (C-i-j)
% % Perception markers at ({\qfdNH + (s+0.5)*\qfdCmpW/6}, {-\r + 0.5})
% % Basement: target / difficulty / abs weight / rel weight, per HOW
% \end{qfdhouse}
% \end{document}
%
% NB: in node text, use $<$ / $>$ for less-than / greater-than under
% OT1 encoding; bare < and > render as Spanish ¡ ¿.
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds} \usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
% Toggles — flip before \begin{qfdhouse} to hide sections.
\newif\ifqfdshowroof \qfdshowrooftrue \newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue \newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue \newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
@@ -135,27 +39,36 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\newif\ifqfdshowimportance \qfdshowimportancetrue \newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue \newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue \newif\ifqfdshowevallegend \qfdshowevallegendtrue
\newif\ifqfdshowtitle \qfdshowtitletrue % title block above the roof
\def\qfdNW{5} % Dimensions — override before \begin{qfdhouse} to resize.
\def\qfdNH{5} \def\qfdNW{5} % number of WHATs (rows)
\def\qfdWhatW{4.0} \def\qfdNH{5} % number of HOWs (columns)
\def\qfdImpW{0.9} \def\qfdWhatW{4.0} % width of WHATs column
\def\qfdCmpW{3} \def\qfdImpW{0.9} % width of importance column
\def\qfdHdrH{2.6} \def\qfdCmpW{3} % width of perception zone
\def\qfdBasementN{4} \def\qfdHdrH{2.6} % height of column-titles band
\def\qfdBasementN{4} % number of basement rows
% Titles & labels — override before \begin{qfdhouse}.
\def\qfdWhatsTitle{Customer needs} \def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%} \def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation} \def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor} \def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent} \def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product} \def\qfdAltOneLabel{Our product} % highlighted in legend
\def\qfdAltTwoLabel{Competitor A} \def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B} \def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation} \def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation} \def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation} \def\qfdEvalTitle{Evaluation}
% Title block above the roof — set both to name the house. Leave
% \qfdProjectTitle empty (default) to draw no title at all.
\def\qfdProjectTitle{} % project / feature name (large, bold)
\def\qfdConcept{} % concept in one sentence; \textbf{} the keywords
% Styles.
\tikzset{ \tikzset{
qfdthin/.style ={line width=0.35pt}, qfdthin/.style ={line width=0.35pt},
qfdmed/.style ={line width=0.7pt}, qfdmed/.style ={line width=0.7pt},
@@ -169,6 +82,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
qfdrel/S/.style={qfdstrong}, qfdrel/S/.style={qfdstrong},
qfdrel/M/.style={qfdmod}, qfdrel/M/.style={qfdmod},
qfdrel/W/.style={qfdweak}, qfdrel/W/.style={qfdweak},
% Three perception-zone alternatives. Index 1 is emphasised.
qfdalt1mk/.style={circle, draw, fill=black, qfdalt1mk/.style={circle, draw, fill=black,
minimum size=6pt, inner sep=0pt, line width=1pt}, minimum size=6pt, inner sep=0pt, line width=1pt},
qfdalt1ln/.style={line width=1.2pt}, qfdalt1ln/.style={line width=1.2pt},
@@ -181,6 +95,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
qfdalt3ln/.style={line width=0.7pt, dotted}, qfdalt3ln/.style={line width=0.7pt, dotted},
} }
% --- Grid lines for every zone. ---
\newcommand{\qfdDrawGrid}{% \newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW); \foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r); \foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
@@ -202,6 +117,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\fi \fi
} }
% --- Roof: diagonal grid + named coordinates (C-i-j) for correlations. ---
\newcommand{\qfdDrawRoof}{% \newcommand{\qfdDrawRoof}{%
\ifqfdshowroof \ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {% \foreach \k in {1,...,\qfdNHm} {%
@@ -226,6 +142,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\fi \fi
} }
% --- Perception scale 0..5 + poor/excellent endpoints + zone title. ---
\newcommand{\qfdDrawScale}{% \newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive \ifqfdshowcompetitive
\foreach \tk in {0,1,2,3,4,5} {% \foreach \tk in {0,1,2,3,4,5} {%
@@ -241,6 +158,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\fi \fi
} }
% --- Importance title (left) and WHATs title (header band). ---
\newcommand{\qfdDrawZoneTitles}{% \newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance \ifqfdshowimportance
\node[rotate=90, anchor=west, font=\footnotesize\bfseries] \node[rotate=90, anchor=west, font=\footnotesize\bfseries]
@@ -251,6 +169,25 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle}; {\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
} }
% --- Title + concept subtitle, centred above the roof apex. ---
\newcommand{\qfdDrawTitle}{%
\ifqfdshowtitle
\ifx\qfdProjectTitle\empty\else
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2} % pre-computed (braces don't do arithmetic)
\node[anchor=south, font=\large\bfseries, align=center]
at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
\ifx\qfdConcept\empty\else
\node[anchor=north, font=\footnotesize\itshape, align=center,
text width=\qfdSubW cm]
at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
\fi
\fi
\fi
}
% --- Outer frames around each zone. ---
\newcommand{\qfdDrawFrames}{% \newcommand{\qfdDrawFrames}{%
\begin{scope}[qfdmed] \begin{scope}[qfdmed]
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW); \draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
@@ -265,6 +202,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\end{scope} \end{scope}
} }
% --- Legend on the right (Relations / Correlations / Evaluation). ---
\newcommand{\qfdDrawLegend}{% \newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend \ifqfdshowlegend
\pgfmathsetmacro{\qfdLegX}{% \pgfmathsetmacro{\qfdLegX}{%
@@ -277,6 +215,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}] \begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
\draw[qfdmed, rounded corners=2pt] \draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom); (-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
% Relations
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1) \node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{\qfdRelTitle}; {\qfdRelTitle};
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15); \draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
@@ -286,6 +225,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\node[anchor=west] at (0.5, -0.95) {Medium (3)}; \node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {}; \node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)}; \node[anchor=west] at (0.5, -1.4) {Weak (1)};
% Correlations (roof)
\ifqfdshowroof \ifqfdshowcorrlegend \ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10) \node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{\qfdCorrTitle}; {\qfdCorrTitle};
@@ -295,6 +235,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative}; \node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative}; \node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
\fi \fi \fi \fi
% Evaluation (3 alternatives)
\ifqfdshowcompetitive \ifqfdshowevallegend \ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{% \pgfmathsetmacro{\qfdEvalTop}{%
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi} -2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
@@ -320,6 +261,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\fi \fi
} }
% --- The environment users wrap their content in. ---
\newenvironment{qfdhouse}{% \newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize, \begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
line cap=round, line join=round] line cap=round, line join=round]
@@ -335,18 +277,23 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\qfdDrawRoof \qfdDrawRoof
\qfdDrawScale \qfdDrawScale
\qfdDrawZoneTitles \qfdDrawZoneTitles
\qfdDrawTitle
}{% }{%
\qfdDrawFrames \qfdDrawFrames
\qfdDrawLegend \qfdDrawLegend
\end{tikzpicture}% \end{tikzpicture}%
} }
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone --- % --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
\def\qfdNW{5} \def\qfdNW{5}
\def\qfdNH{7} \def\qfdNH{7}
\def\qfdWhatW{4.6} \def\qfdWhatW{4.6}
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
\def\qfdWhatsTitle{Goals (WHATs)} \def\qfdWhatsTitle{Goals (WHATs)}
\def\qfdImpTitle{Weight} \def\qfdImpTitle{Weight}
\def\qfdProjectTitle{Macroplan}
\def\qfdConcept{A \textbf{week-granular} plan view that holds every
\textbf{Feature} honest against its \textbf{Original Estimate} and turns
the slips into captured \textbf{Learnings}.}
\qfdshowcompetitivefalse \qfdshowcompetitivefalse
\begin{document} \begin{document}
@@ -409,7 +356,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
% Basement: target / difficulty / abs / rel % Basement: target / difficulty / abs / rel
\foreach \c/\tgt/\diff/\abs/\rel in {% \foreach \c/\tgt/\diff/\abs/\rel in {%
1/{state $\leq$3s}/4/158/24, 1/{state $\leq$1s}/4/158/24,
2/{100\%}/2/111/17, 2/{100\%}/2/111/17,
3/{$\leq$1s}/2/82/13, 3/{$\leq$1s}/2/82/13,
4/{1 block}/2/82/13, 4/{1 block}/2/82/13,
@@ -427,37 +374,154 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
Basement rows (top→bottom): **target · difficulty (15) · absolute weight · relative weight %**. Basement rows (top→bottom): **target · difficulty (15) · absolute weight · relative weight %**.
---
## 1. Goals — the WHATs
| ID | Goal | Weight | Source |
| --- | -------------------------------------------------------------------------------------------- | :----: | -------------------------------- |
| G1 | See at a glance where every Feature stands against its Original Estimate — the honest record | 10 | brief + [CONTEXT.md](CONTEXT.md) |
| G2 | Turn estimation misses into captured Learnings for next time | 8 | brief + [CONTEXT.md](CONTEXT.md) |
| G4 | Author & update the whole plan fast during a weekly review | 8 | brief |
| G3 | Know whether external Milestones are at risk | 7 | brief + [CONTEXT.md](CONTEXT.md) |
| G5 | Hand stakeholders a shareable read-only view | 5 | brief |
## 2. Functions — the HOWs
| ID | Function | Dir | Target (now) |
| --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤1s; Feature name + week axis never lost even when the plan exceeds the viewport |
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
| F5 | Make it easy to attach an optional Learning to a delivered Feature | ↓ | one optional field; rendered in the trailing column when present, blank otherwise |
| F6 | Render a Milestone and flag its unmet required Features | → | vertical line at the correct Week; unmet required Features identifiable |
| F7 | Export the rendered plan as a shareable image (clipboard + download) | → | one click → PNG on clipboard and/or downloaded; fully client-side, no backend |
## 3. Cascade — Goals → Functions → How → Components
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤1s; name + axis never lost_
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
- **G2** Turn estimation misses into captured Learnings _W:8_
- **F5** Make it easy to attach an optional Learning to a delivered Feature _Dir↓ one optional field_
- **How**: a single optional `learning` field on a Feature block; rendered in a persistent trailing column when present
- **Component**: C3 (trailing Learning column), C1 (TOML field)
- **G4** Author & update the whole plan fast during a weekly review _W:8_
- **F4** Add / edit / remove a Feature with a single local edit _Dir↓ one block per op, no ripple_
- **How**: TOML `[[feature]]` / `[[milestone]]` blocks keyed by date literals; renderer derives Weeks & marker placement so edits never ripple. See T2.
- **Component**: **C1 TOML source + parser** (smol-toml) → Plan model
- **F3** Reflect a source edit in the view _Dir↓ ≤1s_
- **How**: in-app split editor, re-parse on every keystroke (instant); autosave to localStorage. Chosen over Vite-HMR-on-file (needs a running toolchain) and load-file-only (slow loop). See T3.
- **Component**: **C4 Split editor** (editor pane + Vue reactivity)
- **G3** Know whether external Milestones are at risk _W:7_
- **F6** Render a Milestone and flag its unmet required Features _Dir→ correct Week; unmet identifiable_
- **How**: `[[milestone]]` references Features by name; renderer draws a vertical line at the milestone Week and marks which required Features are undelivered
- **Component**: C2 (membership + unmet computation), C3 (vertical line overlay)
- **G5** Hand stakeholders a shareable read-only view _W:5_
- **F7** Export the rendered plan as a shareable image _Dir→ one click → PNG_
- **How**: client-side DOM-to-PNG (html-to-image) → clipboard + download. Chosen over hosting a URL, which has no data unless the source is also shipped (local-first tool). See T1, T4.
- **Component**: **C6 Image exporter**
- **F2** Classify each Delivery on-time/late vs the Original Estimate _Dir→ 100% correct per ADR-0001_ (serves G1)
- **How**: pure derivation in the Plan model — compare Delivery Week to the Original Estimate Week; never to a Re-estimate
- **Component**: **C2 Plan model** — derives contiguous Monday Weeks, classifies markers, computes Milestone membership
### Components
| ID | Component | Realises | ADR |
| --- | --------------------------------------------------------------------------------------------------------------- | ------------------ | -------- |
| C1 | TOML source + parser (smol-toml) → Plan model | F4, feeds F2 | ADR-0002 |
| C2 | Plan model — Week derivation, on-time/late classification, Milestone membership | F2, F6 | ADR-0001 |
| C3 | Grid renderer (Vue + CSS Grid) — bars, symbols, colors, Now line, sticky panes, hover, trailing Learning column | F1, F5, F6 | — |
| C4 | Split editor — parse-on-keystroke, localStorage autosave | F3 | ADR-0002 |
| C5 | Plan library — named Macroplans in localStorage, switch/CRUD, Import/Export .toml | scope, persistence | ADR-0002 |
| C6 | Image exporter (html-to-image) — PNG to clipboard + download | F7 | — |
## 4. House — Goals × Functions
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Σ = `Σ(weight × strength)`.
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
| --------- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
| G1 (10) | 9 | 9 | 1 | 1 | | 1 | |
| G2 (8) | 3 | | | | 9 | | |
| G4 (8) | 1 | | 9 | 9 | 3 | 1 | |
| G3 (7) | 3 | 3 | | | | 9 | |
| G5 (5) | 3 | | | | | | 9 |
| **Σ** | 158 | 111 | 82 | 82 | 96 | 81 | 45 |
| **Rel %** | 24 | 17 | 13 | 13 | 15 | 12 | 7 |
**Top engineering priorities:** **F1 (render, 24%)** and **F2 (classify, 17%)** carry the most goal-value — together they _are_ G1, the anchor goal, so the grid renderer and the Plan-model classifier deserve the most care. **F5 (15%)** ranks third despite a single goal because it is the _sole_ driver of G2 (weight 8) — under-investing in frictionless Learning capture silently abandons the learning goal. The authoring pair **F3/F4 (13% each)** matter as a unit. **F7 (7%)** is genuinely a nice-to-have; keep it cheap. (Note: this importance lens differs from §7's risk lens, where F2 ranks first as the hard correctness gate.)
## 5. Roof — Function × Function tradeoffs
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
| **F1** | — | ○ | × | | | | × |
| **F2** | | — | | ○ | | | |
| **F3** | | | — | ◎ | | | |
| **F4** | | | | — | | | |
| **F5** | | | | | — | | |
| **F6** | | | | | | — | |
| **F7** | | | | | | | — |
**Conflicts that actually shape the design:**
- **F1 × F7 (×).** The richer F1's _hover-only_ content, the more an exported image (F7) loses. Mitigation: keep the status _color_ always-visible; only the note is hover-only. Owned by tension in §8.
- **F1 × F3 (×).** A heavier render (sticky panes, many cells) can slow the ≤1s reflect loop. Mitigation in §7: debounce / parse only changed blocks.
- **F3 ◎ F4.** Instant reflect + local ripple-free edits reinforce strongly — together they _are_ fast authoring (G4). Invest in them as a pair.
- **F2 ○ F4.** TOML's explicit date literals feed reliable classification — structured source _helps_ correctness (this is why the earlier "terse-vs-robust" tension dissolved once we chose TOML over a DSL).
## 6. Function → Component map
Strength of each Component in realising each Function (9/3/1/blank). Component list and ADR anchors are in §3.
| | C1 | C2 | C3 | C4 | C5 | C6 |
| ------ | :-: | :-: | :-: | :-: | :-: | :-: |
| **F1** | | 3 | 9 | | | |
| **F2** | 3 | 9 | | | | |
| **F3** | 3 | | | 9 | | |
| **F4** | 9 | | | 3 | | |
| **F5** | 3 | | 9 | | | |
| **F6** | | 9 | 3 | | | |
| **F7** | | | 3 | | | 9 |
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
## 7. Critical performance budget ## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it | | Rank | Function | Target | Watched on | If we miss it |
|------|----------|--------|------------|---------------| | ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop | | 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling | | 2 | F1 | reader IDs a state ≤1s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks | | 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" | | 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |
## 8. Tradeoffs — Got / Paid / ADR ## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR | | ID | Tradeoff | Got | Paid | ADR |
|----|----------|-----|------|-----| | --- | --------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- |
| T1 | DOM/CSS Grid render over monospace text block | sticky panes, real colors, hover, image export | can't paste the plan as plain text; more layout code | — | | T1 | DOM/CSS Grid render over monospace text block | sticky panes, real colors, hover, image export | can't paste the plan as plain text; more layout code | — |
| T2 | TOML over a bespoke terse DSL (and over YAML) | robustness, native date literals, no parser to maintain, forgiving for a non-expert | verbose per Feature; source doesn't *look like* the plan | ADR-0002 | | T2 | TOML over a bespoke terse DSL (and over YAML) | robustness, native date literals, no parser to maintain, forgiving for a non-expert | verbose per Feature; source doesn't _look like_ the plan | ADR-0002 |
| T3 | In-app split editor over Vite-HMR-on-file | instant loop, no toolchain for readers, works from static build | plan isn't a plain repo file by default (mitigated by Import/Export) | ADR-0002 | | T3 | In-app split editor over Vite-HMR-on-file | instant loop, no toolchain for readers, works from static build | plan isn't a plain repo file by default (mitigated by Import/Export) | ADR-0002 |
| T4 | Image export over hosted URL | no data-hosting problem, paste into Slack/decks | snapshot loses hover content (status notes) | — | | T4 | Image export over hosted URL | no data-hosting problem, paste into Slack/decks | snapshot loses hover content (status notes) | — |
| T5 | In-app plan library over single-document | switch plans without file juggling | list/CRUD UI, naming, localStorage to manage | ADR-0002 | | T5 | In-app plan library over single-document | switch plans without file juggling | list/CRUD UI, naming, localStorage to manage | ADR-0002 |
### Tensions being watched (unresolved by design) ### Tensions being watched (unresolved by design)
- **Hover content vanishes in the exported image (F1↔F7).** For now we accept it: the colored status dot is always visible; only the *note* is lost. **Trigger to revisit:** if shared snapshots regularly need the notes, add an "expand notes inline before export" toggle. - **Hover content vanishes in the exported image (F1↔F7).** For now we accept it: the colored status dot is always visible; only the _note_ is lost. **Trigger to revisit:** if shared snapshots regularly need the notes, add an "expand notes inline before export" toggle.
- **localStorage is the only live store (durability).** Accepted because Import/Export .toml is the durable, git-trackable backup. **Trigger to revisit:** first time a plan is lost, or when multi-device editing is needed → consider file-system-access API or a sync backend. - **localStorage is the only live store (durability).** Accepted because Import/Export .toml is the durable, git-trackable backup. **Trigger to revisit:** first time a plan is lost, or when multi-device editing is needed → consider file-system-access API or a sync backend.
- **No multiplayer / real-time collaboration (v1).** Deliberately single-user, local-first per ADR-0002. A future version may add a **backend + database with a CRDT layer syncing edits to the same TOML** (the format and the pure Plan-model derivation are chosen to not block this). **Trigger to revisit:** when two people need to edit one Macroplan at the same time → spike a CRDT (e.g. Yjs) over the TOML document, behind the existing in-app editor. - **No multiplayer / real-time collaboration (v1).** Deliberately single-user, local-first per ADR-0002. A future version may add a **backend + database with a CRDT layer syncing edits to the same TOML** (the format and the pure Plan-model derivation are chosen to not block this). **Trigger to revisit:** when two people need to edit one Macroplan at the same time → spike a CRDT (e.g. Yjs) over the TOML document, behind the existing in-app editor.
## 9. Inconsistencies spotted and fixed ## 9. Inconsistencies spotted and fixed
- **F8 was a solution, not a function.** "Keep names pinned while scrolling" named sticky panes (a How) and an outcome already in G1. Folded legibility-at-scale into F1's target; sticky panes became a How under F1. - **F8 was a solution, not a function.** "Keep names pinned while scrolling" named sticky panes (a How) and an outcome already in G1. Folded legibility-at-scale into F1's target; sticky panes became a How under F1.
- **F5 over-specified coverage.** Originally "Learning on 100% of delivered rows"; corrected — a Learning is *optional*, the function is low-friction capture, not coverage. - **F5 over-specified coverage.** Originally "Learning on 100% of delivered rows"; corrected — a Learning is _optional_, the function is low-friction capture, not coverage.
- **F7 assumed hosting.** Originally "ship as a static hosted artifact"; a hosted URL has no data for a local-first file-based tool. Reframed to client-side image export, which actually serves G5 better. - **F7 assumed hosting.** Originally "ship as a static hosted artifact"; a hosted URL has no data for a local-first file-based tool. Reframed to client-side image export, which actually serves G5 better.
- **F4 conflated terseness with ergonomics.** Originally "~1 feature = 1 line"; the real property for G4 is *local, ripple-free* CRUD. Reframed; terseness target dropped (TOML is not 1-line and that's fine). - **F4 conflated terseness with ergonomics.** Originally "~1 feature = 1 line"; the real property for G4 is _local, ripple-free_ CRUD. Reframed; terseness target dropped (TOML is not 1-line and that's fine).
--- ---

View File

@@ -1,6 +1,6 @@
# Macroplan # Macroplan
A week-granular, **learning-oriented** view of the features a team has committed to deliver — a simpler cousin of a Gantt chart whose point is not just scheduling but learning from *how our estimates held up over time*. A week-by-week plan that keeps the receipts on what a team promised to deliver — a simpler cousin of a Gantt chart whose point is not just scheduling but learning from _how our estimates held up over time_.
Each feature reads left-to-right as a story: where it started, what we first promised, every time it slipped, and when it actually shipped — judged honestly against the **first** estimate, never a moved goalpost. Each feature reads left-to-right as a story: where it started, what we first promised, every time it slipped, and when it actually shipped — judged honestly against the **first** estimate, never a moved goalpost.
@@ -17,9 +17,7 @@ Dashboard ┣━━━━━◯ 🔴 n
## Status ## Status
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, the full week × feature grid render, derived on-time/late classification, milestones, and PNG export all work client-side. **Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, a **library** of named plans, the full week × feature grid render, derived on-time/late classification, milestones, and PNG + `.toml` export all work client-side.
Not yet built: a **library** of multiple named plans. Today a single source autosaves to localStorage.
## How it works ## How it works
@@ -27,13 +25,14 @@ Not yet built: a **library** of multiple named plans. Today a single source auto
- The view is a **CSS-Grid** week × feature layout with the symbol vocabulary, real status colors (🟢/🟠/🔴 with hover notes), a "now" line, sticky feature-name and week-axis panes, and a trailing **Learning** column. - The view is a **CSS-Grid** week × feature layout with the symbol vocabulary, real status colors (🟢/🟠/🔴 with hover notes), a "now" line, sticky feature-name and week-axis panes, and a trailing **Learning** column.
- On-time vs. late is **derived** by the app against the Original Estimate — you never type "late". - On-time vs. late is **derived** by the app against the Original Estimate — you never type "late".
- **Milestones** are vertical lines tied to an explicit list of required features. - **Milestones** are vertical lines tied to an explicit list of required features.
- Your source **autosaves to localStorage**; **export a PNG** to share into Slack or a deck. - Keep a **library** of named plans in localStorage and switch between them; **export** any plan as a `.toml` file, or the rendered view as a **PNG** to share into Slack or a deck.
- Stack: Vite + Vue 3 + DaisyUI · `smol-toml` (parse) · `html-to-image` (export). Static SPA, no backend. - Stack: Vite + Vue 3 + DaisyUI · `smol-toml` (parse) · `html-to-image` (export). Static SPA, no backend.
## Documentation ## Documentation
| Document | What it covers | | 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 | | [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 | | [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/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 |

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,860 @@
# Plans Library Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the app hold several named Macroplans in localStorage — switch between them, create and delete them, and download any plan as a portable `.toml` file.
**Architecture:** `useMacroplan` becomes the owner of a `Library` (a `{version, activeId, plans[]}` object in localStorage), exposing the active plan's authoring state plus switch/create/delete operations. A new presentational `PlanSwitcher.vue` renders the header dropdown. A small `useSourceExport` util downloads the active plan's TOML, reusing a `slugify` helper extracted from the existing PNG exporter.
**Tech Stack:** Vue 3 (`<script setup lang="ts">`), TypeScript, DaisyUI 5, `smol-toml` (already wired via `parseMacroplan`), Vitest + `@vue/test-utils` + happy-dom.
**Spec:** `docs/superpowers/specs/2026-06-17-plans-library-design.md`
## Global Constraints
- Vue 3 `<script setup lang="ts">` for all components; match the existing component style.
- DaisyUI 5 classes for UI (the app pins a light theme); no new dependencies.
- No backend — everything client-side (ADR-0002). localStorage keys: **`macroplan:library`** (new store) and **`macroplan:source`** (legacy, single-source — migrate away from it).
- Vitest defaults to the `node` environment; any test that touches the DOM or `localStorage` MUST start with the line `// @vitest-environment happy-dom` (see `src/components/MacroplanGrid.test.ts`).
- Plan ids come from `crypto.randomUUID()`. Target is modern browsers; no fallback.
- A plan's display **name** is its parsed TOML `title` (cached as last-good); it is **not** a separately edited field.
- **Invariant:** the library always holds ≥ 1 plan.
- Conventional-commit messages, lowercase, imperative. **Do NOT add `Co-authored-by` lines** (user's standing rule).
- Run a single test file with `pnpm exec vitest run <path>`; full suite with `pnpm test`; typecheck + build with `pnpm build`.
---
### Task 1: TOML filename helpers + `downloadSource`
Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename + download utility. Foundational and dependency-free.
**Files:**
- Modify: `src/composables/usePngExport.ts` (extract `slugify`, keep `exportFilename` output identical)
- Create: `src/composables/useSourceExport.ts`
- Test: `src/composables/useSourceExport.test.ts`
- (Unchanged, must still pass: `src/composables/usePngExport.test.ts`)
**Interfaces:**
- Produces: `slugify(title: string): string` (exported from `usePngExport.ts`) — lowercased, non-alphanumerics collapsed to dashes, edge dashes trimmed, **no** extension and **no** `plan` fallback.
- Produces: `sourceFilename(title: string): string``macroplan-<slug-or-plan>.toml`
- Produces: `downloadSource(source: string, filename: string): void`
- [ ] **Step 1: Write the failing test**
Create `src/composables/useSourceExport.test.ts`:
```ts
import { describe, it, expect } from "vitest"
import { sourceFilename } from "./useSourceExport"
describe("sourceFilename", () => {
it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.toml")
})
it("falls back to a generic name when the title has no usable characters", () => {
expect(sourceFilename("")).toBe("macroplan-plan.toml")
expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm exec vitest run src/composables/useSourceExport.test.ts`
Expected: FAIL — cannot resolve `./useSourceExport`.
- [ ] **Step 3: Extract `slugify` in `usePngExport.ts`**
Replace the existing `exportFilename` function (lines 513) with:
```ts
/** Lowercase, dash-collapsed slug of a plan title — no extension, no fallback. */
export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
/** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string {
return `macroplan-${slugify(title) || "plan"}.png`
}
```
- [ ] **Step 4: Create `useSourceExport.ts`**
```ts
import { slugify } from "./usePngExport"
/** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string {
return `macroplan-${slugify(title) || "plan"}.toml`
}
/** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void {
const blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
```
- [ ] **Step 5: Run both filename test files to verify they pass**
Run: `pnpm exec vitest run src/composables/useSourceExport.test.ts src/composables/usePngExport.test.ts`
Expected: PASS (both files; the existing `exportFilename` tests still pass because output is unchanged).
- [ ] **Step 6: Commit**
```bash
git add src/composables/usePngExport.ts src/composables/useSourceExport.ts src/composables/useSourceExport.test.ts
git commit -m "feat(export): add .toml source download and shared slugify helper"
```
---
### Task 2: `PlanSwitcher.vue` (presentational dropdown)
A header dropdown that lists plans, marks the active one, and emits switch / new / download intents. No storage or parsing logic — props in, events out.
**Files:**
- Create: `src/components/PlanSwitcher.vue`
- Test: `src/components/PlanSwitcher.test.ts`
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces: component `PlanSwitcher` with
- Props: `plans: { id: string; name: string }[]`, `activeId: string`
- Emits: `select: [id: string]`, `new: []`, `download: [id: string]`
- DOM contract relied on by the test: each plan is an `<a>` inside a `<li>`; the active plan's `<a>` has class `active`; the last two `<a>` items are **New plan** then **Download .toml**.
- [ ] **Step 1: Write the failing test**
Create `src/components/PlanSwitcher.test.ts`:
```ts
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import PlanSwitcher from "./PlanSwitcher.vue"
const plans = [
{ id: "a", name: "Alpha" },
{ id: "b", name: "Bravo" },
]
describe("PlanSwitcher", () => {
it("lists plan names and marks the active one", () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
expect(w.text()).toContain("Alpha")
expect(w.text()).toContain("Bravo")
expect(w.find("a.active").text()).toContain("Bravo")
})
it("emits select with the clicked plan id", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
await w.findAll("li a")[1].trigger("click") // Bravo
expect(w.emitted("select")?.[0]).toEqual(["b"])
})
it("emits new and download (with the active id) from the trailing actions", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
const actions = w.findAll("li a")
await actions[actions.length - 2].trigger("click") // New plan
await actions[actions.length - 1].trigger("click") // Download .toml
expect(w.emitted("new")).toBeTruthy()
expect(w.emitted("download")?.[0]).toEqual(["a"])
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm exec vitest run src/components/PlanSwitcher.test.ts`
Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
- [ ] **Step 3: Create `PlanSwitcher.vue`**
```vue
<script setup lang="ts">
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
new: []
download: [id: string]
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>
<template>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1 normal-case">
<span class="max-w-[14rem] truncate font-semibold">{{ activeName }}</span>
<span aria-hidden="true"></span>
</div>
<ul
tabindex="0"
class="menu dropdown-content z-50 mt-1 w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="p in plans" :key="p.id">
<a :class="{ active: p.id === activeId }" @click="emit('select', p.id)">
<span class="truncate">{{ p.name }}</span>
<span v-if="p.id === activeId" aria-hidden="true"></span>
</a>
</li>
<div class="my-1 border-t border-base-200"></div>
<li><a @click="emit('new')"> New plan</a></li>
<li><a @click="emit('download', activeId)"> Download .toml</a></li>
</ul>
</div>
</template>
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm exec vitest run src/components/PlanSwitcher.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/components/PlanSwitcher.vue src/components/PlanSwitcher.test.ts
git commit -m "feat(ui): add the plan switcher dropdown"
```
---
### Task 3: `useMacroplan` library refactor
Turn the single-source composable into the Library owner: storage + migration + active-plan binding + autosave, plus the switch/create/delete operations. Keep `resetToSample` exported for now so `App.vue` still compiles — Task 4 removes it.
**Files:**
- Modify (rewrite): `src/composables/useMacroplan.ts`
- Test: `src/composables/useMacroplan.test.ts`
**Interfaces:**
- Consumes: `parseMacroplan` (throws `PlanParseError`; defaults `title` to `'Untitled Macroplan'`), `buildPlan`, `SAMPLE_PLAN` (its title is `"Q3 — Checkout revamp"`).
- Produces (the composable's return shape, relied on by Task 4):
- `source: WritableComputedRef<string>` — reads/writes the active plan's source
- `plan: ComputedRef<Plan | null>`
- `error: ComputedRef<string | null>`
- `plans: ComputedRef<{ id: string; name: string }[]>`
- `activeId: ComputedRef<string>`
- `selectPlan(id: string): void`
- `newPlan(): void`
- `deletePlan(id: string): void`
- `resetToSample(): void` _(temporary — removed in Task 4)_
- [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
Create `src/composables/useMacroplan.test.ts`:
```ts
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from "vue"
import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear())
describe("useMacroplan — load & migration", () => {
it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
})
it("migrates a legacy single-source store into a one-plan library and drops the legacy key", () => {
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp")
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
})
it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("repairs a stale activeId instead of discarding the stored plans", () => {
localStorage.setItem(
LIB_KEY,
JSON.stringify({
version: 1,
activeId: "gone",
plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}),
)
const m = useMacroplan()
expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe("Kept")
})
})
describe("useMacroplan — active plan binding", () => {
it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan()
m.source.value = 'title = "Renamed"\n'
await nextTick()
expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain("Renamed")
})
it("keeps the last-good name and render when the source is mid-edit/broken", async () => {
const m = useMacroplan()
const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick()
expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe("useMacroplan — CRUD", () => {
it("newPlan appends a sample plan and activates it", () => {
const m = useMacroplan()
const firstId = m.activeId.value
m.newPlan()
expect(m.plans.value).toHaveLength(2)
expect(m.activeId.value).not.toBe(firstId)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("deletePlan removes the active plan and re-points to the preceding one", () => {
const m = useMacroplan()
m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value
m.deletePlan(second.id)
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(first.id)
})
it("deleting the last plan re-seeds a fresh sample (never empty)", () => {
const m = useMacroplan()
m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("switching to a broken plan shows its own empty state, not the previous render", async () => {
const m = useMacroplan()
const aId = m.activeId.value
m.newPlan()
const bId = m.activeId.value
m.source.value = 'title = "B"\n[[feature]]\n' // B broken
await nextTick()
m.selectPlan(aId)
await nextTick()
expect(m.plan.value).toBeTruthy() // A renders
m.selectPlan(bId)
await nextTick()
expect(m.error.value).toBeTruthy() // B's parse error surfaces
expect(m.plan.value).toBeNull() // no stale A render leaks through
})
})
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `pnpm exec vitest run src/composables/useMacroplan.test.ts`
Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not exist yet.
- [ ] **Step 3: Rewrite `useMacroplan.ts`**
Replace the entire file with:
```ts
import { ref, computed, watch } from "vue"
import { parseMacroplan } from "../model/parse"
import { buildPlan } from "../model/plan"
import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
export interface StoredPlan {
id: string
name: string
source: string
}
interface Library {
version: 1
activeId: string
plans: StoredPlan[]
}
/** The source's title if it fully parses, else null (so the cached name only
* ever updates on a valid parse). */
function titleOf(source: string): string | null {
try {
return parseMacroplan(source).title
} catch {
return null
}
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(lib))
} catch {
/* localStorage may be full or blocked — autosave is best-effort */
}
}
/** Resolve the initial library: existing store → legacy migration → fresh seed.
* Always persisted before returning so a migrated/seeded library survives a
* reload even if the user never edits. */
function loadLibrary(): Library {
let result: Library | null = null
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const lib = JSON.parse(raw) as Library
if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) {
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
}
} catch {
/* corrupt JSON → fall through to migration / seed */
}
}
if (!result) {
const legacy = localStorage.getItem(LEGACY_KEY)
if (legacy != null) {
const p = newStoredPlan(legacy)
localStorage.removeItem(LEGACY_KEY)
result = { version: 1, activeId: p.id, plans: [p] }
} else {
const seed = newStoredPlan(SAMPLE_PLAN)
result = { version: 1, activeId: seed.id, plans: [seed] }
}
}
save(result)
return result
}
/**
* Owns the Library — the collection of named Macroplans in localStorage
* (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
* error, with the last good render kept through transient typos, F3) plus the
* switch / create / delete operations over the library.
*/
export function useMacroplan() {
const lib = ref<Library>(loadLibrary())
const lastGood = ref<Plan | null>(null)
const active = computed<StoredPlan>(
() => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0],
)
const source = computed<string>({
get: () => active.value.source,
set: (v) => {
const p = active.value
p.source = v
const t = titleOf(v)
if (t) p.name = t // refresh the cached label only on a valid parse
},
})
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
try {
return { plan: buildPlan(parseMacroplan(source.value)), error: null }
} catch (e) {
return { plan: null, error: e instanceof Error ? e.message : String(e) }
}
})
watch(
parsed,
(p) => {
if (p.plan) lastGood.value = p.plan
},
{ immediate: true },
)
// Persist the whole library on any change (best-effort, like the old autosave).
watch(lib, (l) => save(l), { deep: true })
// Switching plans drops the prior render so a broken target shows its own
// error/empty state, not the previous plan's grid.
function switchTo(id: string): void {
lib.value.activeId = id
lastGood.value = parsed.value.plan
}
return {
source,
plan: computed(() => parsed.value.plan ?? lastGood.value),
error: computed(() => parsed.value.error),
plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
activeId: computed(() => lib.value.activeId),
selectPlan: (id: string) => {
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
},
newPlan: () => {
const p = newStoredPlan(SAMPLE_PLAN)
lib.value.plans.push(p)
switchTo(p.id)
},
deletePlan: (id: string) => {
const idx = lib.value.plans.findIndex((p) => p.id === id)
if (idx === -1) return
const wasActive = lib.value.activeId === id
lib.value.plans.splice(idx, 1)
if (lib.value.plans.length === 0) {
const seed = newStoredPlan(SAMPLE_PLAN)
lib.value.plans.push(seed)
switchTo(seed.id)
} else if (wasActive) {
switchTo(lib.value.plans[Math.max(0, idx - 1)].id)
}
},
// TODO(task 4): remove once App.vue drops the "Reset to sample" button.
resetToSample: () => {
source.value = SAMPLE_PLAN
},
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `pnpm exec vitest run src/composables/useMacroplan.test.ts`
Expected: PASS (all groups).
- [ ] **Step 5: Confirm the app still typechecks (App.vue unchanged, `resetToSample` retained)**
Run: `pnpm build`
Expected: build succeeds (no type errors).
- [ ] **Step 6: Commit**
```bash
git add src/composables/useMacroplan.ts src/composables/useMacroplan.test.ts
git commit -m "feat(model): make useMacroplan own a library of named plans"
```
---
### Task 4: Wire `App.vue` (switcher, delete confirm, .toml download)
Replace the static title block with the switcher, add a trash button + confirm modal that deletes the active plan, wire the `.toml` download, and remove the now-retired `resetToSample`.
**Files:**
- Modify (rewrite): `src/App.vue`
- Modify: `src/composables/useMacroplan.ts` (delete the temporary `resetToSample`)
**Interfaces:**
- Consumes: `useMacroplan` (`source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan`), `PlanSwitcher`, `sourceFilename` + `downloadSource`, `exportFilename` + `usePngExport`.
- [ ] **Step 1: Remove the temporary `resetToSample` from `useMacroplan.ts`**
Delete these lines from the returned object:
```ts
// TODO(task 4): remove once App.vue drops the "Reset to sample" button.
resetToSample: () => {
source.value = SAMPLE_PLAN
},
```
- [ ] **Step 2: Rewrite `App.vue`**
Replace the entire file with:
```vue
<script setup lang="ts">
import { ref, computed } from "vue"
import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport()
const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {
downloadSource(source.value, sourceFilename(plan.value?.title ?? activeName.value))
}
function confirmDelete() {
deletePlan(activeId.value)
confirmingDelete.value = false
}
</script>
<template>
<div class="flex h-screen flex-col bg-base-200 font-sans text-base-content">
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" />
<div class="flex-1">
<PlanSwitcher
:plans="plans"
:active-id="activeId"
@select="selectPlan"
@new="newPlan"
@download="downloadToml"
/>
</div>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
title="Copy the rendered plan to the clipboard as a PNG"
@click="copyPng(exportRoot)"
>
<span v-if="busy" class="loading loading-spinner loading-xs"></span>
Copy PNG
</button>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
title="Download the rendered plan as a PNG"
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
>
Download
</button>
<button
class="btn btn-ghost btn-sm"
title="Delete this plan"
@click="confirmingDelete = true"
>
🗑
</button>
</header>
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
<section
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r"
>
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Source (TOML)
</div>
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
</section>
<section class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="plan" ref="exportRoot" class="export-root">
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
<MacroplanGrid :plan="plan" />
</div>
<p v-else class="text-sm text-base-content/60">
Nothing to render yet fix the source on the left.
</p>
</section>
</main>
<dialog class="modal" :class="{ 'modal-open': confirmingDelete }">
<div class="modal-box">
<h3 class="text-base font-semibold">Delete {{ activeName }}?</h3>
<p class="py-2 text-sm text-base-content/70">
This removes the plan from your library. Download its .toml first if you want to keep it.
</p>
<div class="modal-action">
<button class="btn btn-sm" @click="confirmingDelete = false">Cancel</button>
<button class="btn btn-sm btn-error" @click="confirmDelete">Delete</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="confirmingDelete = false">
<button>close</button>
</form>
</dialog>
<div v-if="toast" class="toast toast-end">
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
<span>{{ toast.text }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* While exporting, grow to the timeline's full width and un-clip the grid so the
captured PNG shows every week column (not just the on-screen scroll window).
The base-200 frame + padding give the snapshot a little breathing room. */
.export-root.exporting {
width: max-content;
padding: 1rem 1.25rem;
background: var(--color-base-200);
}
.export-root.exporting :deep(.macroplan) {
overflow: visible;
}
</style>
```
- [ ] **Step 3: Typecheck + build**
Run: `pnpm build`
Expected: build succeeds — no reference to `resetToSample` remains, all props/events typecheck.
- [ ] **Step 4: Run the full test suite**
Run: `pnpm test`
Expected: PASS — all suites (existing + the new filename, switcher, and library suites).
- [ ] **Step 5: Manual smoke check in the dev server**
Run: `pnpm dev` and open the app, then verify each:
1. The header shows the current plan name in a dropdown (no more static title).
2. Open the dropdown → ** New plan** adds a plan and switches to it (editor shows the sample again).
3. Edit the `title` in the source → the dropdown label updates to the new title.
4. Switch back to the first plan via the dropdown → its source returns.
5. **⤓ Download .toml** downloads a `macroplan-<slug>.toml` file containing the current source.
6. The 🗑 button opens a confirm modal naming the active plan; **Cancel** dismisses, **Delete** removes it and switches to the preceding plan.
7. Delete every plan in turn → the last delete leaves a fresh sample plan (never an empty app).
8. Reload the page → the library and active plan persist.
Expected: all eight behave as described.
- [ ] **Step 6: Commit**
```bash
git add src/App.vue src/composables/useMacroplan.ts
git commit -m "feat(ui): wire the plan switcher, delete confirm and .toml download into the app"
```
---
### Task 5: Documentation
Record the new `Library` term in the ubiquitous language and flip the README now that the library exists.
**Files:**
- Modify: `CONTEXT.md`
- Modify: `README.md`
- [ ] **Step 1: Add the `Library` term to `CONTEXT.md`**
In the `## Language` section, immediately after the **Now line** entry (the block ending `_Avoid_: today marker, cursor`) and before `## Symbols`, insert:
```markdown
**Library**:
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
```
- [ ] **Step 2: Add a Library relationship line to `CONTEXT.md`**
In `## Relationships`, after the first bullet (the one starting `A **Macroplan** contains a flat, author-ordered list...`), add:
```markdown
- The **Library** holds many **Macroplans**, exactly one of which is active (shown in the editor and grid). Each is identified internally by a stable id and labelled by its **title**.
```
- [ ] **Step 3: Update the README Status paragraph**
Replace:
```markdown
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, the full week × feature grid render, derived on-time/late classification, milestones, and PNG export all work client-side.
Not yet built: a **library** of multiple named plans. Today a single source autosaves to localStorage.
```
with:
```markdown
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, a **library** of named plans, the full week × feature grid render, derived on-time/late classification, milestones, and PNG + `.toml` export all work client-side.
```
- [ ] **Step 4: Update the README "How it works" persistence bullet**
Replace:
```markdown
- Your source **autosaves to localStorage**; **export a PNG** to share into Slack or a deck.
```
with:
```markdown
- Keep a **library** of named plans in localStorage and switch between them; **export** any plan as a `.toml` file, or the rendered view as a **PNG** to share into Slack or a deck.
```
- [ ] **Step 5: Verify the docs build is unaffected and commit**
Run: `pnpm test`
Expected: PASS (docs-only change; suites still green).
```bash
git add CONTEXT.md README.md
git commit -m "docs: record the Library term and mark the plans library as built"
```
---
## Self-Review
**1. Spec coverage**
- Multiple named plans in localStorage → Task 3 (`Library` model).
- Header dropdown switcher → Task 2 (`PlanSwitcher`) + Task 4 (wiring).
- New plan from sample → Task 3 (`newPlan`) + Task 4.
- Delete active plan with confirm → Task 3 (`deletePlan`) + Task 4 (modal).
- `.toml` download → Task 1 (`sourceFilename`/`downloadSource`) + Task 4.
- Migration from legacy key + always-≥1 invariant + corrupt fallback + stale-activeId repair → Task 3 (tested).
- Name = cached last-good title → Task 3 (`titleOf` in the `source` setter, tested).
- `slugify` extracted, `exportFilename` output unchanged → Task 1 (existing test still passes).
- Switch resets last-good render → Task 3 (`switchTo`, tested).
- `Library` glossary term + README flip → Task 5.
- All spec requirements map to a task. No gaps.
**2. Placeholder scan:** No TBD/TODO-as-deliverable, no "add error handling" hand-waves; every code step shows complete code. The one `TODO(task 4)` comment is a deliberate, scheduled removal (Task 4, Step 1), not an unfinished placeholder.
**3. Type consistency:** `slugify` (Task 1) is consumed by `sourceFilename` (Task 1) and unchanged `exportFilename`. `useMacroplan`'s return shape (Task 3) — `source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan` — matches exactly what `App.vue` destructures (Task 4). `PlanSwitcher` props (`plans: {id,name}[]`, `activeId: string`) and emits (`select`, `new`, `download`) match the App template bindings. `StoredPlan`/`Library` are internal to `useMacroplan`. Consistent throughout.

View File

@@ -0,0 +1,163 @@
# Plans Library — Design
**Date:** 2026-06-17
**Status:** Approved (brainstorming) → ready for implementation plan
**Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — _"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."_
## Problem
The app stores exactly one Macroplan, autosaved to a single localStorage key (`macroplan:source`). ADR-0002 always described localStorage as **a library of named Macroplans**, but only the single-plan slice was built. The README advertised a "library of plans" the app never had.
This adds the library: keep several named Macroplans, switch between them, create and delete them, and download any plan as a portable `.toml` file. The `.toml` download is in scope because ADR-0002 makes export the durability mechanism ("durability rests on Export"); a library of N plans in localStorage with no file export would multiply un-backupable state.
## Scope
**In:**
- Multiple named Macroplans persisted in localStorage.
- A header dropdown to switch the active plan.
- Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library).
- Delete the active plan (with a confirm).
- Download the active plan's source as `<title-slug>.toml`.
- Migration from the legacy single-key store.
**Out (YAGNI for v1):**
- Duplicate / clone a plan (fork via _New_ + paste).
- File-open `.toml` _import_ — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy.
- Reordering, folders, search, tags.
- Any backend or multi-device sync (explicitly rejected by ADR-0002).
## Vocabulary
A new term enters the [ubiquitous language](../../../CONTEXT.md):
> **Library** — the collection of saved **Macroplans** held in the browser's localStorage. The live store; durability rests on exporting a plan's `.toml` (per ADR-0002). Not a Macroplan itself, and carries no status of its own.
A plan's **display name** is its TOML `title` (the existing concept), not a separate field — see Data Model.
## Data Model
One new localStorage key **replaces** `macroplan:source`:
```
macroplan:library → {
version: 1,
activeId: string,
plans: [ { id: string, name: string, source: string } ]
}
```
| Field | Meaning |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. |
| `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. |
| `source` | The TOML string the editor binds to. |
**Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3.
### Invariant
**The library always holds ≥ 1 plan.** Deleting the last plan creates a fresh sample plan rather than leaving an empty store. This keeps `activeId` always valid and the editor always bound to something.
### Migration
On load, `useMacroplan` resolves the initial library:
1. If `macroplan:library` exists and parses → use it.
2. Else if the legacy `macroplan:source` exists → wrap that source as a single plan (`name` = its parsed title, or `"Untitled"` if it doesn't parse), mark it active, write `macroplan:library`, and **remove** the legacy key.
3. Else → seed one sample plan, active.
A corrupt/unparseable `macroplan:library` value is treated as case 3 (seed fresh) rather than throwing — autosave is best-effort, consistent with the existing try/catch around `localStorage.setItem`.
## Architecture
### `useMacroplan` (refactor) — the library owner
Holds `plans` and `activeId`; persists the whole library to `macroplan:library` on any change (best-effort, in a try/catch as today).
Exposes:
| Member | Type | Notes |
| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source` | writable `Ref<string>` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. |
| `plan` | `ComputedRef<Plan \| null>` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). |
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
| `activeId` | `Ref<string>` | Current selection. |
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. |
| `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). |
| `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
`resetToSample` is **removed**`newPlan()` (a blank page) replaces it.
### `PlanSwitcher.vue` (new) — presentational
- **Props:** `plans: { id: string; name: string }[]`, `activeId: string`.
- **Emits:** `select(id)`, `new`, `delete(id)`, `download(id)`.
- Renders a DaisyUI dropdown: the active plan's name as the trigger; a list of plans (active one checked); then ` New plan` and `⤓ Download .toml` entries.
- No storage or parsing logic — it only reflects props and emits intent.
### `downloadSource` + `slugify` (new util) — `.toml` export
- Refactor `exportFilename` (in `usePngExport.ts`) to build on an extracted `slugify(title): string` helper, so PNG and `.toml` names stay consistent:
- `exportFilename(title)``macroplan-<slug>.png` (unchanged output; existing test still passes).
- `sourceFilename(title)``macroplan-<slug>.toml`.
- `downloadSource(source: string, filename: string)`: `Blob(['…'], { type: 'text/plain' })` → object URL → anchor click → revoke. Mirrors the existing `downloadPng` mechanics. Lives in a small util (e.g. `src/composables/useSourceExport.ts`) — kept separate from `usePngExport` since it's source export, not render export.
### App shell (`App.vue`)
- The static `<h1>Macroplan</h1>` + subtitle block in the header is **replaced** by `<PlanSwitcher>` (logo kept).
- A **trash** button is added to the top bar; it deletes the **active** plan behind a confirm modal (`Delete "<name>"?` · Cancel / Delete). DaisyUI modal or `<dialog>`.
- `Copy PNG` and `Download` (PNG) buttons are **unchanged**`.toml` download lives in the switcher dropdown precisely so there is no competing "Download" label in the top bar.
- Wire `PlanSwitcher` events: `select → selectPlan`, `new → newPlan`, `delete → open confirm → deletePlan`, `download → downloadSource(source, sourceFilename(plan.title))`.
## Data Flow
```
edit TOML ─► source (active plan) ─► autosave whole library ─► localStorage
└─► parse ─► plan/error ─► refresh active plan's cached name (on valid title)
switch ─► selectPlan(id) ─► activeId changes ─► source rebinds, lastGood reset ─► re-parse ─► render
new ─► newPlan() ─► append blank plan ─► selectPlan(newId)
delete ─► confirm ─► deletePlan(activeId) ─► re-point active to preceding plan (or re-seed if last) ─► render
toml ─► downloadSource(source, sourceFilename(title)) ─► browser download
```
## Error Handling
- **localStorage write fails** (full/blocked): swallowed in try/catch; the in-memory library stays usable for the session — same posture as today's autosave.
- **Corrupt `macroplan:library` on read:** fall back to seeding a fresh sample (don't throw).
- **Active plan source unparseable:** the grid keeps the last-good render and shows the error in the editor — existing behavior, now per active plan.
- **`crypto.randomUUID` unavailable:** target is modern browsers (the app already uses Clipboard API, `color-mix`, dynamic import); no fallback needed. Note in the plan if a wider support floor is required.
## Testing (Vitest, matching the existing suite)
**`useMacroplan` / library model:**
- No storage → library seeds one sample plan, active.
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
- Corrupt `macroplan:library` → seeds fresh sample (no throw).
- `newPlan()` appends a **blank** plan and makes it active (the sample only seeds an empty library).
- `deletePlan(active)` removes it and re-points `activeId` to a remaining plan.
- `deletePlan` of the **last** plan re-seeds a fresh sample (invariant holds).
- Editing `source` updates the active entry and refreshes its cached `name` on a valid title; a broken edit keeps the last-good `name` and render.
- `selectPlan` swaps `source`/`plan`/`error` and resets `lastGood` (broken target shows its own error, not the prior render).
**Filename util:**
- `sourceFilename(title)` slug mirrors the existing `exportFilename` test (e.g. `"Q3 Delivery"``macroplan-q3-delivery.toml`); empty/garbage title → `macroplan-plan.toml`.
**Component (`@vue/test-utils`):**
- `PlanSwitcher` renders the plan names, marks the active one, and emits `select` / `new` / `delete` / `download` on the right interactions.
## Docs to update (in the implementation plan)
- **`CONTEXT.md`** — add the **Library** glossary entry above.
- **`README.md`** — flip the _"Not yet built: a library …"_ note now that it exists; the _How it works_ localStorage bullet can mention named plans + `.toml` export.
## Open questions
None blocking. Possible future follow-ups (out of scope): `.toml` file-open import, duplicate-plan, and reordering.

View File

@@ -1,19 +1,24 @@
{ {
"name": "macroplan", "name": "macroplan",
"private": true,
"version": "0.0.0", "version": "0.0.0",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run" "test": "vitest run",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
}, },
"dependencies": { "dependencies": {
"daisyui": "^5.5.23", "daisyui": "^5.5.23",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"shiki": "^4.2.0", "shiki": "^4.2.0",
"smol-toml": "^1.6.1", "smol-toml": "^1.6.1",
"valibot": "^1.4.1",
"vue": "^3.5.34" "vue": "^3.5.34"
}, },
"devDependencies": { "devDependencies": {
@@ -23,6 +28,8 @@
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.10.4", "happy-dom": "^20.10.4",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",

457
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
smol-toml: smol-toml:
specifier: ^1.6.1 specifier: ^1.6.1
version: 1.6.1 version: 1.6.1
valibot:
specifier: ^1.4.1
version: 1.4.1(typescript@6.0.3)
vue: vue:
specifier: ^3.5.34 specifier: ^3.5.34
version: 3.5.38(typescript@6.0.3) version: 3.5.38(typescript@6.0.3)
@@ -42,6 +45,12 @@ importers:
happy-dom: happy-dom:
specifier: ^20.10.4 specifier: ^20.10.4
version: 20.10.4 version: 20.10.4
oxfmt:
specifier: ^0.55.0
version: 0.55.0
oxlint:
specifier: ^1.70.0
version: 1.70.0
tailwindcss: tailwindcss:
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.3.1 version: 4.3.1
@@ -118,6 +127,250 @@ packages:
'@oxc-project/types@0.133.0': '@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
'@oxfmt/binding-android-arm-eabi@0.55.0':
resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.55.0':
resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.55.0':
resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.55.0':
resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.55.0':
resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.55.0':
resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.55.0':
resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.55.0':
resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.55.0':
resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.55.0':
resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.70.0':
resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.70.0':
resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.70.0':
resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.70.0':
resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.70.0':
resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.70.0':
resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.70.0':
resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.70.0':
resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.70.0':
resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.70.0':
resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.70.0':
resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.70.0':
resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.70.0':
resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.70.0':
resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.70.0':
resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -801,6 +1054,32 @@ packages:
oniguruma-to-es@4.3.6: oniguruma-to-es@4.3.6:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
oxfmt@0.55.0:
resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
svelte:
optional: true
vite-plus:
optional: true
oxlint@1.70.0:
resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -927,6 +1206,10 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@3.1.0: tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -960,6 +1243,14 @@ packages:
unist-util-visit@5.1.0: unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
valibot@1.4.1:
resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-message@4.0.3: vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -1177,6 +1468,120 @@ snapshots:
'@oxc-project/types@0.133.0': {} '@oxc-project/types@0.133.0': {}
'@oxfmt/binding-android-arm-eabi@0.55.0':
optional: true
'@oxfmt/binding-android-arm64@0.55.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.55.0':
optional: true
'@oxfmt/binding-darwin-x64@0.55.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.55.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.55.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.55.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.55.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.55.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.55.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.70.0':
optional: true
'@oxlint/binding-android-arm64@1.70.0':
optional: true
'@oxlint/binding-darwin-arm64@1.70.0':
optional: true
'@oxlint/binding-darwin-x64@1.70.0':
optional: true
'@oxlint/binding-freebsd-x64@1.70.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.70.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.70.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.70.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.70.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.70.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.70.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.70.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.70.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.70.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.70.0':
optional: true
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -1803,6 +2208,52 @@ snapshots:
regex: 6.1.0 regex: 6.1.0
regex-recursion: 6.0.2 regex-recursion: 6.0.2
oxfmt@0.55.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.55.0
'@oxfmt/binding-android-arm64': 0.55.0
'@oxfmt/binding-darwin-arm64': 0.55.0
'@oxfmt/binding-darwin-x64': 0.55.0
'@oxfmt/binding-freebsd-x64': 0.55.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.55.0
'@oxfmt/binding-linux-arm-musleabihf': 0.55.0
'@oxfmt/binding-linux-arm64-gnu': 0.55.0
'@oxfmt/binding-linux-arm64-musl': 0.55.0
'@oxfmt/binding-linux-ppc64-gnu': 0.55.0
'@oxfmt/binding-linux-riscv64-gnu': 0.55.0
'@oxfmt/binding-linux-riscv64-musl': 0.55.0
'@oxfmt/binding-linux-s390x-gnu': 0.55.0
'@oxfmt/binding-linux-x64-gnu': 0.55.0
'@oxfmt/binding-linux-x64-musl': 0.55.0
'@oxfmt/binding-openharmony-arm64': 0.55.0
'@oxfmt/binding-win32-arm64-msvc': 0.55.0
'@oxfmt/binding-win32-ia32-msvc': 0.55.0
'@oxfmt/binding-win32-x64-msvc': 0.55.0
oxlint@1.70.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.70.0
'@oxlint/binding-android-arm64': 1.70.0
'@oxlint/binding-darwin-arm64': 1.70.0
'@oxlint/binding-darwin-x64': 1.70.0
'@oxlint/binding-freebsd-x64': 1.70.0
'@oxlint/binding-linux-arm-gnueabihf': 1.70.0
'@oxlint/binding-linux-arm-musleabihf': 1.70.0
'@oxlint/binding-linux-arm64-gnu': 1.70.0
'@oxlint/binding-linux-arm64-musl': 1.70.0
'@oxlint/binding-linux-ppc64-gnu': 1.70.0
'@oxlint/binding-linux-riscv64-gnu': 1.70.0
'@oxlint/binding-linux-riscv64-musl': 1.70.0
'@oxlint/binding-linux-s390x-gnu': 1.70.0
'@oxlint/binding-linux-x64-gnu': 1.70.0
'@oxlint/binding-linux-x64-musl': 1.70.0
'@oxlint/binding-openharmony-arm64': 1.70.0
'@oxlint/binding-win32-arm64-msvc': 1.70.0
'@oxlint/binding-win32-ia32-msvc': 1.70.0
'@oxlint/binding-win32-x64-msvc': 1.70.0
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
@@ -1932,6 +2383,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinypool@2.1.0: {}
tinyrainbow@3.1.0: {} tinyrainbow@3.1.0: {}
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
@@ -1966,6 +2419,10 @@ snapshots:
unist-util-is: 6.0.1 unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2 unist-util-visit-parents: 6.0.2
valibot@1.4.1(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
vfile-message@4.0.3: vfile-message@4.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3

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

89
src/App.test.ts Normal file
View File

@@ -0,0 +1,89 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from "vitest"
import { mount } from "@vue/test-utils"
import { nextTick } from "vue"
// downloadSource performs a real DOM/Blob download; mock it so we can assert
// the wiring without touching the filesystem. sourceFilename stays real.
vi.mock("./composables/useSourceExport", async (importOriginal) => {
const actual = await importOriginal<typeof import("./composables/useSourceExport")>()
return { ...actual, downloadSource: vi.fn() }
})
import App from "./App.vue"
import { downloadSource } from "./composables/useSourceExport"
import { SAMPLE_PLAN } from "./data/sample"
// The editor lazy-loads Shiki and the grid is heavy; neither is under test here.
const stubs = { PlanEditor: true, MacroplanGrid: true }
function mountApp() {
return mount(App, { global: { stubs } })
}
// dropdown links = one <a> per plan, then "New plan" and "Download .toml".
function dropdownLinks(w: ReturnType<typeof mountApp>) {
return w.findAll(".dropdown-content li a")
}
beforeEach(() => localStorage.clear())
describe("App — plan library wiring", () => {
it("shows the active plan name in the header switcher", () => {
expect(mountApp().text()).toContain("Q3 — Checkout revamp")
})
it("creates a new plan from the dropdown", async () => {
const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan + New + Download
await dropdownLinks(w)[1].trigger("click") // New plan
await nextTick()
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download
})
it("opens the confirm modal and deletes the active plan on confirm", async () => {
const w = mountApp()
await dropdownLinks(w)[1].trigger("click") // New → 2 plans
await nextTick()
expect(dropdownLinks(w)).toHaveLength(4)
await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find(".modal-open").exists()).toBe(true)
await w.find(".modal-action .btn-error").trigger("click") // Delete
await nextTick()
expect(w.find(".modal-open").exists()).toBe(false)
expect(dropdownLinks(w)).toHaveLength(3) // back to 1 plan
})
it("cancel dismisses the modal without deleting", async () => {
const w = mountApp()
await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find(".modal-open").exists()).toBe(true)
const cancel = w.findAll(".modal-action .btn").find((b) => !b.classes().includes("btn-error"))!
await cancel.trigger("click")
expect(w.find(".modal-open").exists()).toBe(false)
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched
})
it("re-seeds a fresh sample when the last plan is deleted (never empty)", async () => {
const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan
await w.find('button[title="Delete this plan"]').trigger("click")
await w.find(".modal-action .btn-error").trigger("click") // Delete the only plan
await nextTick()
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded
expect(w.text()).toContain("Q3 — Checkout revamp")
})
it("wires the dropdown .toml download to downloadSource with the active source and a .toml name", async () => {
const w = mountApp()
await dropdownLinks(w)[2].trigger("click") // Download .toml (1 plan → index 2)
expect(downloadSource).toHaveBeenCalledOnce()
const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0]
expect(src).toBe(SAMPLE_PLAN)
expect(filename).toBe("macroplan-q3-checkout-revamp.toml")
})
})

View File

@@ -1,14 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, watch } from "vue"
import { useMacroplan } from './composables/useMacroplan' import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from './composables/usePngExport' import { usePngExport, exportFilename } from "./composables/usePngExport"
import PlanEditor from './components/PlanEditor.vue' import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import MacroplanGrid from './components/MacroplanGrid.vue' import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, resetToSample } = useMacroplan() const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport() const { busy, toast, copyPng, downloadPng } = usePngExport()
const exportRoot = ref<HTMLElement>() const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
// Which panes are visible: the Source editor, the rendered Macroplan, or both.
// Kept in the URL (?view=…) so a layout is shareable/bookmarkable, not persisted.
type ViewMode = "source" | "split" | "macroplan"
function readViewMode(): ViewMode {
const v = new URLSearchParams(location.search).get("view")
return v === "source" || v === "macroplan" ? v : "split"
}
const mode = ref<ViewMode>(readViewMode())
const showSource = computed(() => mode.value !== "macroplan")
const showMacroplan = computed(() => mode.value !== "source")
// Reflect the choice in the URL without stacking a history entry per toggle.
// "split" is the default, so drop the param to keep the URL clean.
watch(mode, (m) => {
const url = new URL(location.href)
if (m === "split") url.searchParams.delete("view")
else url.searchParams.set("view", m)
history.replaceState(history.state, "", url)
})
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {
downloadSource(source.value, sourceFilename(plan.value?.title ?? activeName.value))
}
function confirmDelete() {
deletePlan(activeId.value)
confirmingDelete.value = false
}
</script> </script>
<template> <template>
@@ -16,14 +54,46 @@ const exportRoot = ref<HTMLElement>()
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2"> <header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
<img src="/favicon.svg" alt="" class="size-6" /> <img src="/favicon.svg" alt="" class="size-6" />
<div class="flex-1"> <div class="flex-1">
<h1 class="text-lg font-semibold leading-tight">Macroplan</h1> <PlanSwitcher
<p class="text-xs text-base-content/60 leading-tight"> :plans="plans"
A week-granular, learning-oriented record of what we committed to deliver. :active-id="activeId"
</p> @select="selectPlan"
@new="newPlan"
@download="downloadToml"
/>
</div>
<div class="join" role="group" aria-label="Choose which panes to show">
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mode === 'source' }"
:aria-pressed="mode === 'source'"
title="Show only the TOML source"
@click="mode = 'source'"
>
Source
</button>
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mode === 'split' }"
:aria-pressed="mode === 'split'"
title="Show the source and the Macroplan side by side"
@click="mode = 'split'"
>
Split
</button>
<button
class="btn btn-sm join-item"
:class="{ 'btn-active': mode === 'macroplan' }"
:aria-pressed="mode === 'macroplan'"
title="Show only the rendered Macroplan"
@click="mode = 'macroplan'"
>
Macroplan
</button>
</div> </div>
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
:disabled="!plan || busy" :disabled="!plan || busy || !showMacroplan"
title="Copy the rendered plan to the clipboard as a PNG" title="Copy the rendered plan to the clipboard as a PNG"
@click="copyPng(exportRoot)" @click="copyPng(exportRoot)"
> >
@@ -32,18 +102,29 @@ const exportRoot = ref<HTMLElement>()
</button> </button>
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
:disabled="!plan || busy" :disabled="!plan || busy || !showMacroplan"
title="Download the rendered plan as a PNG" title="Download the rendered plan as a PNG"
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))" @click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
> >
Download Download
</button> </button>
<button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button> <button
class="btn btn-ghost btn-sm"
:disabled="busy"
title="Delete this plan"
@click="confirmingDelete = true"
>
🗑
</button>
</header> </header>
<main class="flex min-h-0 flex-1 flex-col md:flex-row"> <main class="flex min-h-0 flex-1 flex-col md:flex-row">
<section <section
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r" v-if="showSource"
class="flex min-h-0 flex-col border-base-300"
:class="
showMacroplan ? 'max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r' : 'flex-1'
"
> >
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50"> <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Source (TOML) Source (TOML)
@@ -51,7 +132,7 @@ const exportRoot = ref<HTMLElement>()
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" /> <PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
</section> </section>
<section class="min-h-0 flex-1 overflow-auto p-4"> <section v-if="showMacroplan" class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="plan" ref="exportRoot" class="export-root"> <div v-if="plan" ref="exportRoot" class="export-root">
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2> <h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
<MacroplanGrid :plan="plan" /> <MacroplanGrid :plan="plan" />
@@ -62,6 +143,22 @@ const exportRoot = ref<HTMLElement>()
</section> </section>
</main> </main>
<dialog class="modal" :class="{ 'modal-open': confirmingDelete }">
<div class="modal-box">
<h3 class="text-base font-semibold">Delete "{{ activeName }}"?</h3>
<p class="py-2 text-sm text-base-content/70">
This removes the plan from your library. Download its .toml first if you want to keep it.
</p>
<div class="modal-action">
<button class="btn btn-sm" @click="confirmingDelete = false">Cancel</button>
<button class="btn btn-sm btn-error" @click="confirmDelete">Delete</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="confirmingDelete = false">
<button>close</button>
</form>
</dialog>
<div v-if="toast" class="toast toast-end"> <div v-if="toast" class="toast toast-end">
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'"> <div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
<span>{{ toast.text }}</span> <span>{{ toast.text }}</span>
@@ -82,4 +179,12 @@ const exportRoot = ref<HTMLElement>()
.export-root.exporting :deep(.macroplan) { .export-root.exporting :deep(.macroplan) {
overflow: visible; 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> </style>

View File

@@ -20,8 +20,15 @@ Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as
```vue ```vue
<template> <template>
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" <svg
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> class="size-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- paste paths from the Tabler SVG here --> <!-- paste paths from the Tabler SVG here -->
</svg> </svg>
</template> </template>

View File

@@ -1,52 +1,52 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { mount } from '@vue/test-utils' import { mount } from "@vue/test-utils"
import MacroplanGrid from './MacroplanGrid.vue' import MacroplanGrid from "./MacroplanGrid.vue"
import { parseMacroplan } from '../model/parse' import { parseMacroplan } from "../model/parse"
import { buildPlan } from '../model/plan' import { buildPlan } from "../model/plan"
import { SAMPLE_PLAN } from '../data/sample' import { SAMPLE_PLAN } from "../data/sample"
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), '2026-06-17') const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), "2026-06-17")
function mountGrid() { function mountGrid() {
return mount(MacroplanGrid, { props: { plan } }) return mount(MacroplanGrid, { props: { plan } })
} }
describe('MacroplanGrid renders the sample plan', () => { describe("MacroplanGrid renders the sample plan", () => {
it('renders one row per feature, in order', () => { it("renders one row per feature, in order", () => {
const names = mountGrid() const names = mountGrid()
.findAll('.namecell') .findAll(".namecell")
.map((n) => n.text()) .map((n) => n.text())
expect(names).toEqual(['Auth', 'Payments', 'Dashboard', 'Search', 'Notifications']) expect(names).toEqual(["Auth", "Payments", "Dashboard", "Search", "Notifications"])
}) })
it('renders the right markers per feature', () => { it("renders the right markers per feature", () => {
const rows = mountGrid().findAll('.namecell') const rows = mountGrid().findAll(".namecell")
// each feature row is namecell + week cells + learncell; grab the row text via the grid // each feature row is namecell + week cells + learncell; grab the row text via the grid
const grid = mountGrid() const grid = mountGrid()
const text = grid.text() const text = grid.text()
expect(rows).toHaveLength(5) expect(rows).toHaveLength(5)
// on-time, late, and slip glyphs all present somewhere in the grid // on-time, late, and slip glyphs all present somewhere in the grid
expect(text).toContain('◉') // Auth delivered on time expect(text).toContain("◉") // Auth delivered on time
expect(text).toContain('▲') // Payments delivered late expect(text).toContain("▲") // Payments delivered late
expect(text).toContain('△') // re-estimates expect(text).toContain("△") // re-estimates
expect(text).toContain('◯') // open original estimates expect(text).toContain("◯") // open original estimates
}) })
it('draws a now column and the milestone header', () => { it("draws a now column and the milestone header", () => {
const w = mountGrid() const w = mountGrid()
expect(w.find('.col-now').exists()).toBe(true) expect(w.find(".col-now").exists()).toBe(true)
expect(w.text()).toContain('now') expect(w.text()).toContain("now")
expect(w.text()).toContain('MVP go-live') expect(w.text()).toContain("MVP go-live")
}) })
it('shows a learning for a delivered feature and a status note for an in-flight one', () => { it("shows a learning for a delivered feature and a status note for an in-flight one", () => {
const text = mountGrid().text() const text = mountGrid().text()
expect(text).toContain('Vendor lead time') // Payments learning expect(text).toContain("Vendor lead time") // Payments learning
expect(text).toContain('No recovery plan yet') // Dashboard status note expect(text).toContain("No recovery plan yet") // Dashboard status note
}) })
it('labels week columns', () => { it("labels week columns", () => {
expect(mountGrid().text()).toContain('Jun 15') expect(mountGrid().text()).toContain("Jun 15")
}) })
}) })

View File

@@ -1,52 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue"
import type { Plan, FeatureRow, MarkerKind } from '../model/types' import type { Plan, FeatureRow, MarkerKind } from "../model/types"
import { weekLabel, type WeekId } from '../model/week' import { weekLabel, type WeekId } from "../model/week"
const props = defineProps<{ plan: Plan }>() const props = defineProps<{ plan: Plan }>()
type Tone = 'success' | 'warning' | 'error' | 'neutral' type Tone = "success" | "warning" | "error" | "neutral"
const GLYPH: Record<MarkerKind, string> = { const GLYPH: Record<MarkerKind, string> = {
original: '◯', original: "◯",
reestimate: '△', reestimate: "△",
'delivered-on-time': '◉', "delivered-on-time": "◉",
'delivered-late': '▲', "delivered-late": "▲",
} }
const MARKER_CLASS: Record<MarkerKind, string> = { const MARKER_CLASS: Record<MarkerKind, string> = {
original: 'text-base-content/50', original: "text-base-content/50",
reestimate: 'text-warning', reestimate: "text-warning",
'delivered-on-time': 'text-success', "delivered-on-time": "text-success",
'delivered-late': 'text-error', "delivered-late": "text-error",
} }
// When two markers land on the same week, the higher rank owns the cell. // When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = { const RANK: Record<MarkerKind, number> = {
'delivered-late': 3, "delivered-late": 3,
'delivered-on-time': 3, "delivered-on-time": 3,
reestimate: 2, reestimate: 2,
original: 1, original: 1,
} }
const TONE_TEXT: Record<Tone, string> = { const TONE_TEXT: Record<Tone, string> = {
success: 'text-success', success: "text-success",
warning: 'text-warning', warning: "text-warning",
error: 'text-error', error: "text-error",
neutral: 'text-primary', neutral: "text-primary",
} }
const TONE_BORDER: Record<Tone, string> = { const TONE_BORDER: Record<Tone, string> = {
success: 'border-success', success: "border-success",
warning: 'border-warning', warning: "border-warning",
error: 'border-error', error: "border-error",
neutral: 'border-primary', neutral: "border-primary",
} }
const TONE_DOT: Record<Tone, string> = { const TONE_DOT: Record<Tone, string> = {
success: 'bg-success', success: "bg-success",
warning: 'bg-warning', warning: "bg-warning",
error: 'bg-error', error: "bg-error",
neutral: 'bg-base-300', neutral: "bg-base-300",
} }
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math. // 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 WK = 3.5 * 16
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
@@ -57,21 +57,15 @@ const gridStyle = computed(() => ({
})) }))
function tone(row: FeatureRow): Tone { function tone(row: FeatureRow): Tone {
if (row.delivered) return row.onTime ? 'success' : 'error' if (row.delivered) return row.onTime ? "success" : "error"
if (row.status === 'green') return 'success' if (row.status === "on-track") return "success"
if (row.status === 'orange') return 'warning' if (row.status === "at-risk") return "warning"
if (row.status === 'red') return 'error' if (row.status === "off-track") return "error"
return 'neutral' return "neutral"
} }
function statusWord(row: FeatureRow): string { function statusWord(row: FeatureRow): string {
return row.status === 'green' return row.status ? row.status.replace("-", " ") : "in flight"
? 'on track'
: row.status === 'orange'
? 'at risk'
: row.status === 'red'
? 'blocked'
: 'in flight'
} }
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null { function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
@@ -85,7 +79,7 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
interface Cell { interface Cell {
// how far the bar line runs within this cell: none, center→right, // how far the bar line runs within this cell: none, center→right,
// left→center, or full width // left→center, or full width
line: 'none' | 'right' | 'left' | 'full' line: "none" | "right" | "left" | "full"
isStart: boolean isStart: boolean
glyph: string glyph: string
glyphCls: string glyphCls: string
@@ -97,17 +91,17 @@ const matrix = computed<Cell[][]>(() =>
weeks.value.map((w) => { weeks.value.map((w) => {
const m = markerAt(row, w) const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell['line'] = 'none' let line: Cell["line"] = "none"
if (inBar) { if (inBar) {
const isStart = w === row.startWeek const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek const isEnd = w === row.barEndWeek
line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full' line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
} }
return { return {
line, line,
isStart: inBar && w === row.startWeek, isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : '', glyph: m ? GLYPH[m] : "",
glyphCls: m ? MARKER_CLASS[m] : '', glyphCls: m ? MARKER_CLASS[m] : "",
} }
}), }),
), ),
@@ -125,9 +119,9 @@ const cols = computed(() =>
function colClass(i: number): string { function colClass(i: number): string {
const c = cols.value[i] const c = cols.value[i]
if (c.isNow) return 'col-now' if (c.isNow) return "col-now"
if (c.isMilestone) return 'col-ms' if (c.isMilestone) return "col-ms"
return '' return ""
} }
// Milestone label flags above the axis, greedily stacked onto extra rows so // Milestone label flags above the axis, greedily stacked onto extra rows so
@@ -140,13 +134,13 @@ const milestoneFlags = computed(() => {
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
return items.map((x) => { return items.map((x) => {
const startX = NAME_W + (x.col - 2) * WK const startX = NAME_W + (x.col - 2) * WK
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : '') const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
const width = text.length * BAND_CHAR + 14 const width = text.length * BAND_CHAR + 14
let row = 0 let row = 0
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++ while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
rowEnd[row] = startX + width rowEnd[row] = startX + width
const title = x.m.unmet.length const title = x.m.unmet.length
? `${x.m.name} — unmet: ${x.m.unmet.join(', ')}` ? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
: `${x.m.name} — all required features met` : `${x.m.name} — all required features met`
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title } return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
}) })
@@ -173,7 +167,7 @@ const bandStyle = computed(() => ({
<div class="plan-grid" :style="gridStyle"> <div class="plan-grid" :style="gridStyle">
<!-- header row --> <!-- header row -->
<div class="hcell corner">{{ plan.title }}</div> <div class="hcell corner">Feature</div>
<div <div
v-for="(c, ci) in cols" v-for="(c, ci) in cols"
:key="'h-' + c.w" :key="'h-' + c.w"
@@ -203,7 +197,9 @@ const bandStyle = computed(() => ({
<div class="learncell"> <div class="learncell">
<template v-if="row.delivered"> <template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span> <span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span> <span v-else class="muted">{{
row.onTime ? "delivered on time" : "delivered late"
}}</span>
</template> </template>
<template v-else> <template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span> <span class="dot" :class="TONE_DOT[tone(row)]"></span>
@@ -228,7 +224,7 @@ const bandStyle = computed(() => ({
<style scoped> <style scoped>
.macroplan { .macroplan {
--name-w: 9rem; --name-w: 16rem;
--wk: 3.5rem; --wk: 3.5rem;
} }
.plan-grid { .plan-grid {

View File

@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeAll } from "vitest"
import { mount } from "@vue/test-utils"
import PlanEditor from "./PlanEditor.vue"
// happy-dom ships no canvas 2D context; measure() only needs measureText, so a
// tiny stub keeps onMounted from throwing without pulling in a canvas polyfill.
beforeAll(() => {
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 80 }),
font: "",
})) as unknown as HTMLCanvasElement["getContext"]
})
/** Mount with a working v-model so emitted edits flow back into the prop. */
function mountEditor(initial = "") {
const wrapper = mount(PlanEditor, {
props: {
modelValue: initial,
error: null,
"onUpdate:modelValue": (v: string) => wrapper.setProps({ modelValue: v }),
},
})
return wrapper
}
/** Type `text` into the textarea (caret at the end) and let the popup refresh. */
async function type(wrapper: ReturnType<typeof mountEditor>, text: string) {
const ta = wrapper.find("textarea")
const el = ta.element as HTMLTextAreaElement
el.value = text
el.selectionStart = el.selectionEnd = text.length
await ta.trigger("input")
}
const lastEmit = (wrapper: ReturnType<typeof mountEditor>) => {
const events = wrapper.emitted("update:modelValue")
return events?.at(-1)?.[0] as string | undefined
}
describe("PlanEditor completion keys", () => {
it("opens the popup as the author types a key prefix", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
expect(wrapper.find(".completion").exists()).toBe(true)
expect(wrapper.findAll(".completion .label").map((el) => el.text())).toContain("title")
})
it("Tab accepts the selected item without navigating first", async () => {
const wrapper = mountEditor()
await type(wrapper, "t")
await wrapper.find("textarea").trigger("keydown", { key: "Tab" })
expect(lastEmit(wrapper)).toBe("title = ")
expect(wrapper.find(".completion").exists()).toBe(false)
})
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" })
expect(lastEmit(wrapper)).toBe("title = ")
expect(wrapper.find(".completion").exists()).toBe(false)
})
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: "Escape" })
expect(wrapper.find(".completion").exists()).toBe(false)
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
// Popup is gone, so Enter accepts nothing — the value stays as typed.
expect(lastEmit(wrapper)).toBe("t")
})
})

View File

@@ -1,22 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from 'shiki/core' import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from "./completion"
import { fitPopup } from "./popupFit"
const props = defineProps<{ modelValue: string; error: string | null }>() const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>() const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>() const backdrop = ref<HTMLElement>()
const popupEl = ref<HTMLElement>()
const highlighter = ref<HighlighterCore>() const highlighter = ref<HighlighterCore>()
// ── Completion popup state ───────────────────────────────────────────────────
const completion = ref<CompletionContext | null>(null)
const selected = ref(0)
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
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
onMounted(async () => { onMounted(async () => {
measure()
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle. // Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM). // Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
const [core, engine, toml, theme] = await Promise.all([ const [core, engine, toml, theme] = await Promise.all([
import('shiki/core'), import("shiki/core"),
import('shiki/engine/javascript'), import("shiki/engine/javascript"),
import('shiki/langs/toml.mjs'), import("shiki/langs/toml.mjs"),
import('shiki/themes/github-light.mjs'), import("shiki/themes/github-light.mjs"),
]) ])
highlighter.value = await core.createHighlighterCore({ highlighter.value = await core.createHighlighterCore({
themes: [theme.default], themes: [theme.default],
@@ -25,26 +39,151 @@ onMounted(async () => {
}) })
}) })
function measure() {
const el = textarea.value
if (!el) return
const cs = getComputedStyle(el)
const ctx = document.createElement("canvas").getContext("2d")!
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
metrics = {
charWidth: ctx.measureText("0000000000").width / 10,
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
padLeft: parseFloat(cs.paddingLeft),
padTop: parseFloat(cs.paddingTop),
}
}
/** Recompute suggestions from the live textarea value + caret. */
function refresh() {
const el = textarea.value
if (!el) return
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
selected.value = 0
if (completion.value) position()
}
/** Place the popup just below the caret (monospace ⇒ column × charWidth). */
function position() {
const el = textarea.value
if (!el) return
const before = el.value.slice(0, el.selectionStart)
const lineIndex = before.split("\n").length - 1
const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
popup.value = { left: caretLeft, top: caretTop + metrics.lineHeight }
// The popup has no measurable size until Vue has rendered it; adjust once it does.
nextTick(() => keepInView(caretLeft, caretTop))
}
/** Nudge the popup back inside the window once it has a measurable size. */
function keepInView(caretLeft: number, caretTop: number) {
const el = textarea.value
const list = popupEl.value
if (!el || !list) return
// popup left/top are relative to `.code`, which the textarea fills exactly, so
// the textarea rect bridges those local coordinates to the window.
const ta = el.getBoundingClientRect()
const box = list.getBoundingClientRect()
popup.value = fitPopup({
caret: { left: caretLeft, top: caretTop },
lineHeight: metrics.lineHeight,
box: { width: box.width, height: box.height },
origin: { left: ta.left, top: ta.top },
viewport: { width: window.innerWidth, height: window.innerHeight },
})
}
function accept(i: number) {
const el = textarea.value
const ctx = completion.value
if (!el || !ctx) return
const item = ctx.items[i]
const caret = ctx.from + item.insert.length
completion.value = null
emit("update:modelValue", el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
nextTick(() => {
el.setSelectionRange(caret, caret)
el.focus()
// Chain only after completing a key (insert ends `= `), so its values pop
// up next. A value or header is terminal — re-running would re-offer it.
if (item.insert.endsWith("= ")) refresh()
})
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") justEscaped = false
const ctx = completion.value
if (!ctx) return
const move = (delta: number) => {
e.preventDefault()
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
}
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
move(e.key === "n" ? 1 : -1)
return
}
switch (e.key) {
case "ArrowDown":
move(1)
break
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 "Escape":
e.preventDefault()
completion.value = null
justEscaped = true
break
// Caret jumps would leave the popup stranded at a stale spot — close it.
case "ArrowLeft":
case "ArrowRight":
case "Home":
case "End":
completion.value = null
break
}
}
const highlighted = computed(() => { const highlighted = computed(() => {
// a trailing newline needs a trailing char so the last backdrop line keeps height // a trailing newline needs a trailing char so the last backdrop line keeps height
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue const code = props.modelValue.endsWith("\n") ? props.modelValue + " " : props.modelValue
const hl = highlighter.value const hl = highlighter.value
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>` if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
return hl.codeToHtml(code, { lang: 'toml', theme: 'github-light' }) return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
}) })
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!) return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)
} }
function onInput(e: Event) { function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value) emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
// Deleting text (backspace, forward-delete, cut) shouldn't summon the popup —
// the author is removing, not asking for suggestions. Close it and stop.
if ((e as InputEvent).inputType?.startsWith("delete")) {
completion.value = null
return
}
refresh()
} }
function syncScroll() { function syncScroll() {
if (!textarea.value || !backdrop.value) return if (!textarea.value || !backdrop.value) return
backdrop.value.scrollTop = textarea.value.scrollTop backdrop.value.scrollTop = textarea.value.scrollTop
backdrop.value.scrollLeft = textarea.value.scrollLeft backdrop.value.scrollLeft = textarea.value.scrollLeft
if (completion.value) position()
} }
</script> </script>
@@ -63,7 +202,25 @@ function syncScroll() {
aria-label="Macroplan TOML source" aria-label="Macroplan TOML source"
@input="onInput" @input="onInput"
@scroll="syncScroll" @scroll="syncScroll"
@keydown="onKeydown"
@blur="completion = null"
></textarea> ></textarea>
<ul
v-if="completion"
ref="popupEl"
class="completion"
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
>
<li
v-for="(item, i) in completion.items"
:key="item.label"
:class="{ active: i === selected }"
@mousedown.prevent="accept(i)"
>
<span class="label">{{ item.label }}</span>
<span v-if="item.detail" class="detail">{{ item.detail }}</span>
</li>
</ul>
</div> </div>
<div v-if="error" class="editor-error" role="alert"> <div v-if="error" class="editor-error" role="alert">
<span class="font-semibold">Cant parse:</span> {{ error }} <span class="font-semibold">Cant parse:</span> {{ error }}
@@ -95,7 +252,11 @@ function syncScroll() {
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
font-family: inherit; /* Fira Code, from the global theme */ font-family: inherit; /* Fira Code, from the global theme */
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.6; /* Integer px, NOT a unitless ratio: a fractional line box (0.8rem × 1.6 =
20.48px) is rounded differently by a <textarea> than by the <pre> backdrop,
so the caret drifts further from its glyph the more lines precede it —
visible at the bottom of a tall file. An integer height rounds identically. */
line-height: 20px;
tab-size: 2; tab-size: 2;
white-space: pre; white-space: pre;
} }
@@ -133,6 +294,42 @@ function syncScroll() {
.input::selection { .input::selection {
background: color-mix(in oklch, var(--color-primary) 28%, transparent); background: color-mix(in oklch, var(--color-primary) 28%, transparent);
} }
/* Completion popup, anchored just below the caret (see position()). */
.completion {
position: absolute;
z-index: 2;
margin: 0;
padding: 0.25rem;
list-style: none;
min-width: 16rem;
max-width: 30rem;
max-height: 14rem;
overflow-y: auto;
background: var(--color-base-100);
border: 1px solid var(--color-base-300);
border-radius: 0.4rem;
box-shadow: 0 8px 24px color-mix(in oklch, black 16%, transparent);
}
.completion li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.25rem;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
white-space: nowrap;
}
.completion li.active {
background: color-mix(in oklch, var(--color-primary) 18%, transparent);
}
.completion .label {
font-size: 0.78rem;
}
.completion .detail {
font-size: 0.68rem;
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
}
.editor-error { .editor-error {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-size: 0.74rem; font-size: 0.74rem;

View File

@@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import PlanSwitcher from "./PlanSwitcher.vue"
const plans = [
{ id: "a", name: "Alpha" },
{ id: "b", name: "Bravo" },
]
describe("PlanSwitcher", () => {
it("lists plan names and marks the active one", () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
expect(w.text()).toContain("Alpha")
expect(w.text()).toContain("Bravo")
expect(w.find("a.active").text()).toContain("Bravo")
})
it("emits select with the clicked plan id", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
await w.findAll("li a")[1].trigger("click") // Bravo
expect(w.emitted("select")?.[0]).toEqual(["b"])
})
it("emits new and download (with the active id) from the trailing actions", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
const actions = w.findAll("li a")
await actions[actions.length - 2].trigger("click") // New plan
await actions[actions.length - 1].trigger("click") // Download .toml
expect(w.emitted("new")).toBeTruthy()
expect(w.emitted("download")?.[0]).toEqual(["a"])
})
})

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
new: []
download: [id: string]
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>
<template>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1 normal-case">
<span class="max-w-[14rem] truncate font-semibold">{{ activeName }}</span>
<span aria-hidden="true"></span>
</div>
<ul
tabindex="0"
class="menu dropdown-content z-50 mt-1 w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li v-for="p in plans" :key="p.id">
<a :class="{ active: p.id === activeId }" @click="emit('select', p.id)">
<span class="truncate">{{ p.name }}</span>
<span v-if="p.id === activeId" aria-hidden="true"></span>
</a>
</li>
<div class="my-1 border-t border-base-200"></div>
<li><a @click="emit('new')"> New plan</a></li>
<li><a @click="emit('download', activeId)"> Download .toml</a></li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest"
import { getCompletions } from "./completion"
/** Split a `|`-marked string into (source, caret offset). */
function atCaret(marked: string): [string, number] {
const caret = marked.indexOf("|")
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
}
/** The labels offered at the `|` marker. */
function labelsAt(marked: string): string[] {
const [source, caret] = atCaret(marked)
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
}
describe("completion context", () => {
it("offers plan keys on an empty top-level line", () => {
// Headers wait for a blank line above; at the very top there's nothing above.
expect(labelsAt("|")).toEqual(["title", "start", "end"])
})
it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
expect(labelsAt("[[feature]]\n|")).toEqual([
"name",
"start",
"original",
"reestimates",
"delivered",
"status",
"learning",
"note",
])
})
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
})
it("keeps offering remaining keys on a single new line, still without headers", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
})
it("volunteers block headers only after a blank line (two new lines)", () => {
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
// Directly under the last property: nothing left to add ⇒ no popup.
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
// A blank line signals a new block is wanted ⇒ offer the headers.
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
})
it("volunteers headers after a blank line even with keys still open", () => {
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
"week",
"requires",
"[[feature]]",
"[[milestone]]",
])
})
it("excludes keys already written in the current block", () => {
const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|')
expect(labels).not.toContain("name")
expect(labels).not.toContain("original")
expect(labels).toContain("start")
})
it("filters keys by the typed prefix", () => {
expect(labelsAt("[[feature]]\nst|")).toEqual(["start", "status"])
})
it("completes a header from a leading bracket", () => {
expect(labelsAt("[[f|")).toEqual(["[[feature]]"])
})
it("suggests status values after status =", () => {
expect(labelsAt("[[feature]]\nstatus = |")).toEqual(["on-track", "at-risk", "off-track"])
})
it("filters status values by the typed prefix, inside an open quote", () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(["on-track", "off-track"])
})
it("offers nothing once the status value's quote is closed", () => {
expect(getCompletions(...atCaret('[[feature]]\nstatus = "on-track"|'))).toBeNull()
})
it("suggests feature names inside a milestone requires array", () => {
const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
expect(labelsAt(source)).toEqual(["Payments"])
})
it("does not offer feature names outside a milestone block", () => {
// `requires` is meaningless at the plan level, so there is nothing to add.
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
})
it("returns nothing in a value position it cannot complete (a date)", () => {
expect(getCompletions(...atCaret("[[feature]]\nstart = 2026-|"))).toBeNull()
})
})
describe("date completion", () => {
const TODAY = "2026-06-19"
const datesAt = (marked: string) => {
const [source, caret] = atCaret(marked)
return getCompletions(source, caret, TODAY)
}
it("suggests today then the current-year prefix for an empty date value", () => {
const ctx = datesAt("[[feature]]\nstart = |")!
expect(ctx.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
expect(ctx.items.map((i) => i.detail)).toEqual(["today", "this year"])
})
it("offers the same for plan dates and milestone weeks", () => {
expect(datesAt("end = |")!.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
expect(datesAt("[[milestone]]\nweek = |")!.items.map((i) => i.insert)).toEqual([
"2026-06-19",
"2026-",
])
})
it("inserts at the caret, replacing nothing", () => {
const [source, caret] = atCaret("[[feature]]\ndelivered = |")
const ctx = getCompletions(source, caret, TODAY)!
expect([ctx.from, ctx.to]).toEqual([caret, caret])
})
it("offers a date for a fresh element in a reestimates array", () => {
expect(datesAt("[[feature]]\nreestimates = [|")!.items.map((i) => i.insert)).toEqual([
"2026-06-19",
"2026-",
])
expect(
datesAt("[[feature]]\nreestimates = [2026-01-01, |")!.items.map((i) => i.insert),
).toEqual(["2026-06-19", "2026-"])
})
it("stays out of the way once a date is being typed", () => {
expect(datesAt("[[feature]]\nstart = 2|")).toBeNull()
expect(datesAt("[[feature]]\nstart = 2026-|")).toBeNull()
expect(datesAt("[[feature]]\nreestimates = [2026-0|")).toBeNull()
})
})
describe("completion replace range", () => {
it("replaces the typed prefix, not the whole line", () => {
const source = "[[feature]]\nst"
const ctx = getCompletions(source, source.length)!
expect(source.slice(ctx.from, ctx.to)).toBe("st")
})
it("replaces a partial bracketed header from the bracket", () => {
const source = "[[f"
const ctx = getCompletions(source, source.length)!
expect(source.slice(ctx.from, ctx.to)).toBe("[[f")
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

@@ -0,0 +1,222 @@
// Schema-aware completion for the Macroplan TOML editor.
//
// The key / value lists below mirror the schema in `src/model/parse.ts`.
// Keep them in sync when fields are added, removed, or renamed.
export interface Completion {
/** Text shown in the popup. */
label: string
/** Text inserted in place of source[from..to]. */
insert: string
/** Greyed hint shown after the label. */
detail?: string
/** Used for prefix matching when it differs from `label` (e.g. headers). */
filter?: string
}
export interface CompletionContext {
/** Replace source[from..to] with the chosen completion's `insert`. */
from: number
to: number
items: Completion[]
}
type Block = "plan" | "feature" | "milestone"
const PLAN_KEYS: Completion[] = [
{ label: "title", insert: "title = ", detail: "plan title" },
{ label: "start", insert: "start = ", detail: "left edge — date, optional" },
{ label: "end", insert: "end = ", detail: "right edge — date, optional" },
]
const FEATURE_KEYS: Completion[] = [
{ label: "name", insert: "name = ", detail: "required" },
{ label: "start", insert: "start = ", detail: "date, required" },
{ label: "original", insert: "original = ", detail: "Original Estimate — date, required" },
{ label: "reestimates", insert: "reestimates = ", detail: "list of dates" },
{ label: "delivered", insert: "delivered = ", detail: "date" },
{ label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" },
{ label: "learning", insert: "learning = ", detail: "string" },
{ label: "note", insert: "note = ", detail: "string" },
]
const MILESTONE_KEYS: Completion[] = [
{ label: "name", insert: "name = ", detail: "required" },
{ label: "week", insert: "week = ", detail: "date, required" },
{ label: "requires", insert: "requires = ", detail: "list of feature names" },
]
const HEADERS: Completion[] = [
{ label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" },
{ label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" },
]
const STATUSES = ["on-track", "at-risk", "off-track"] // keep in sync with parse.ts
/** What to suggest at `caret` in `source`, or null when nothing applies.
* `today` (yyyy-mm-dd) seeds the date suggestions; injectable for tests. */
export function getCompletions(
source: string,
caret: number,
today: string = todayYmd(),
): CompletionContext | null {
const lineStart = source.lastIndexOf("\n", caret - 1) + 1
const linePrefix = source.slice(lineStart, caret)
// ── value: status = "<here>" — while typing, not once the quote is closed ──
const status = /^(\s*status\s*=\s*)"?([A-Za-z-]*)$/.exec(linePrefix)
if (status) {
const items = filter(
STATUSES.map((s) => ({ label: s, insert: `"${s}"` })),
status[2],
)
return result(lineStart + status[1].length, caret, items)
}
// ── value: a date field — start = <here>, week = <here>, … ────────────────
// Only on an empty value, so it never fights a date already being typed
// (the chained refresh after picking the key surfaces these immediately).
if (/^\s*(?:start|end|original|delivered|week)\s*=\s*$/.test(linePrefix)) {
return result(caret, caret, dateItems(today))
}
// ── value: reestimates = [ … <here> ] — a date for a fresh array element ──
const inReestimates = /reestimates\s*=\s*\[[^\]]*$/.test(linePrefix)
if (inReestimates && /[[,]\s*$/.test(linePrefix)) {
return result(caret, caret, dateItems(today))
}
const block = currentBlock(source, lineStart)
// ── value: requires = [ "<here>" … ] (milestones only) ───────────────────
if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
const token = /("?)([^",[\]]*)$/.exec(linePrefix)!
const items = filter(
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
token[2],
)
return result(caret - token[1].length - token[2].length, caret, items)
}
// ── key / header position: only indentation then an optional word ─────────
const key = /^(\s*)(\[*)([A-Za-z]*)$/.exec(linePrefix)
if (key) {
const [, indent, brackets, word] = key
if (brackets) {
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
}
const keys =
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
const taken = presentKeys(source, lineStart, block)
const remaining = keys.filter((k) => !taken.has(k.label))
// Volunteer a new block header only once there's a blank line above: a new
// [[…]] wants a gap before it, so offering one right under the last property
// (or straight after the header line) pops the menu up too eagerly. Typing a
// bare `[` still summons headers anywhere (handled above). With all keys set
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
const items = filter(
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
word,
)
return result(caret - word.length, caret, items)
}
return null
}
function result(from: number, to: number, items: Completion[]): CompletionContext | null {
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 {
if (lineStart === 0) return false // top of the document — nothing above
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
}
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
function todayYmd(): string {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** Date suggestions: the full date (easy to tweak the day/month in place) and
* the current-year prefix (accept it, then type the month-day). */
function dateItems(today: string): Completion[] {
return [
{ label: today, insert: today, detail: "today" },
{ label: today.slice(0, 5), insert: today.slice(0, 5), detail: "this year" },
]
}
function filter(items: Completion[], word: string): Completion[] {
if (!word) return items
const w = word.toLowerCase()
return items.filter((i) => (i.filter ?? i.label).toLowerCase().startsWith(w))
}
const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm
/** The block owning the line at `lineStart` — 'plan' before the first header. */
function currentBlock(source: string, lineStart: number): Block {
let block: Block = "plan"
HEADER_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
block = m[1] as Block
}
return block
}
/** Keys already written in the current block, so we don't offer duplicates. */
function presentKeys(source: string, lineStart: number, block: Block): Set<string> {
const headers: number[] = []
HEADER_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = HEADER_RE.exec(source)) !== null) headers.push(m.index)
let start = 0
let end = source.length
if (block === "plan") {
end = headers[0] ?? source.length
} else {
for (let i = 0; i < headers.length; i++) {
if (headers[i] < lineStart) {
start = headers[i]
end = headers[i + 1] ?? source.length
} else break
}
}
const taken = new Set<string>()
const region = source.slice(start, end)
const keyLine = /^[ \t]*([A-Za-z][\w-]*)\s*=/gm
while ((m = keyLine.exec(region)) !== null) taken.add(m[1])
return taken
}
/** Names declared in `[[feature]]` blocks — the valid targets for `requires`. */
function featureNames(source: string): string[] {
const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm
const names = new Set<string>()
let block: Block = "plan"
let m: RegExpExecArray | null
while ((m = re.exec(source)) !== null) {
if (m[1]) block = m[1] as Block
else if (m[2] && block === "feature") names.add(m[2])
}
return [...names]
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest"
import { fitPopup } from "./popupFit"
// A roomy window with the editor pinned at the top-left, so `origin` adds nothing
// and only the caret/box geometry drives the result.
const base = {
lineHeight: 20,
box: { width: 256, height: 224 },
origin: { left: 0, top: 0 },
viewport: { width: 1000, height: 800 },
margin: 8,
}
describe("fitPopup", () => {
it("anchors just below the caret when it fits", () => {
const { left, top } = fitPopup({ ...base, caret: { left: 100, top: 200 } })
expect(left).toBe(100)
expect(top).toBe(220) // caret.top + lineHeight
})
it("shifts left so the right edge clears the window", () => {
// caret.left 900 + box 256 = 1156, past the 992 usable width by 164.
const { left } = fitPopup({ ...base, caret: { left: 900, top: 200 } })
expect(left).toBe(736) // 900 - 164
expect(left + base.box.width).toBe(base.viewport.width - base.margin)
})
it("flips above the caret line when there's no room below", () => {
// caret near the bottom: below would be 780+224 = 1004, past 792.
const { top } = fitPopup({ ...base, caret: { left: 100, top: 780 } })
expect(top).toBe(780 - 224) // popup bottom now sits on the caret line
})
it("never pushes the flipped popup off the top of the window", () => {
// Tiny window: neither below nor a full flip fits, so clamp to the margin.
const { top } = fitPopup({
...base,
caret: { left: 100, top: 40 },
viewport: { width: 1000, height: 120 },
})
expect(top).toBe(base.margin)
})
it("accounts for the editor's offset within the window (origin)", () => {
// Editor pushed 800px right; a caret at local 100 sits at window x=900, so the
// 256-wide box overflows and must shift left even though caret.left is small.
const { left } = fitPopup({
...base,
caret: { left: 100, top: 200 },
origin: { left: 800, top: 0 },
})
expect(800 + left + base.box.width).toBe(base.viewport.width - base.margin)
})
})

View File

@@ -0,0 +1,33 @@
/** Geometry for keeping the completion popup inside the browser window.
*
* The popup is anchored just below the caret. Near the right or bottom edge of
* the window that anchor spills off-screen and the popup gets clipped, so we:
* • shift it left until its right edge clears the window, and
* • flip it above the caret line when there's no room below.
*
* Coordinates are local to the editor (the textarea's border box); `origin` is
* that box's own top-left in the window, the bridge from local to window space.
*/
export function fitPopup(opts: {
caret: { left: number; top: number }
lineHeight: number
box: { width: number; height: number }
origin: { left: number; top: number }
viewport: { width: number; height: number }
margin?: number
}): { left: number; top: number } {
const { caret, lineHeight, box, origin, viewport } = opts
const margin = opts.margin ?? 8
let left = caret.left
const overRight = origin.left + left + box.width - (viewport.width - margin)
if (overRight > 0) left = Math.max(margin - origin.left, left - overRight)
let top = caret.top + lineHeight
if (origin.top + top + box.height > viewport.height - margin) {
// Not enough room below — flip above, but never off the top of the window.
top = Math.max(margin - origin.top, caret.top - box.height)
}
return { left, top }
}

View File

@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from "vue"
import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear())
describe("useMacroplan — load & migration", () => {
it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
})
it("migrates a legacy single-source store into a one-plan library and drops the legacy key", () => {
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp")
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
})
it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("falls back to a fresh sample when the stored library shape is malformed", () => {
// Valid JSON, wrong shape: a plan missing its `source` — would have slipped
// through the old blind `as Library` cast.
localStorage.setItem(
LIB_KEY,
JSON.stringify({ version: 1, activeId: "x", plans: [{ id: "x", name: "Bad" }] }),
)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("repairs a stale activeId instead of discarding the stored plans", () => {
localStorage.setItem(
LIB_KEY,
JSON.stringify({
version: 1,
activeId: "gone",
plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}),
)
const m = useMacroplan()
expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe("Kept")
})
})
describe("useMacroplan — active plan binding", () => {
it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan()
m.source.value = 'title = "Renamed"\n'
await nextTick()
expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain("Renamed")
})
it("keeps the last-good name and render when the source is mid-edit/broken", async () => {
const m = useMacroplan()
const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick()
expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe("useMacroplan — CRUD", () => {
it("newPlan appends a blank plan and activates it (the sample only seeds an empty library)", () => {
const m = useMacroplan()
const firstId = m.activeId.value
m.newPlan()
expect(m.plans.value).toHaveLength(2)
expect(m.activeId.value).not.toBe(firstId)
expect(m.source.value).toBe("") // blank page, not a copy of the sample
})
it("deletePlan removes the active plan and re-points to the preceding one", () => {
const m = useMacroplan()
m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value
m.deletePlan(second.id)
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(first.id)
})
it("deleting the last plan re-seeds a fresh sample (never empty)", () => {
const m = useMacroplan()
m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it("switching to a broken plan shows its own empty state, not the previous render", async () => {
const m = useMacroplan()
const aId = m.activeId.value
m.newPlan()
const bId = m.activeId.value
m.source.value = 'title = "B"\n[[feature]]\n' // B broken
await nextTick()
m.selectPlan(aId)
await nextTick()
expect(m.plan.value).toBeTruthy() // A renders
m.selectPlan(bId)
await nextTick()
expect(m.error.value).toBeTruthy() // B's parse error surfaces
expect(m.plan.value).toBeNull() // no stale A render leaks through
})
})

View File

@@ -1,19 +1,108 @@
import { ref, computed, watch } from 'vue' import { ref, shallowRef, computed, watch } from "vue"
import { parseMacroplan } from '../model/parse' import * as v from "valibot"
import { buildPlan } from '../model/plan' import { parseMacroplan } from "../model/parse"
import type { Plan } from '../model/types' import { buildPlan } from "../model/plan"
import { SAMPLE_PLAN } from '../data/sample' import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = 'macroplan:source' const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
const StoredPlanSchema = v.object({
id: v.string(),
name: v.string(),
source: v.string(),
})
export type StoredPlan = v.InferOutput<typeof StoredPlanSchema>
const LibrarySchema = v.object({
version: v.literal(1),
activeId: v.string(),
plans: v.array(StoredPlanSchema),
})
type Library = v.InferOutput<typeof LibrarySchema>
/** The source's title if it fully parses, else null (so the cached name only
* ever updates on a valid parse). */
function titleOf(source: string): string | null {
try {
return parseMacroplan(source).title
} catch {
return null
}
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(lib))
} catch {
/* localStorage may be full or blocked — autosave is best-effort */
}
}
/** Resolve the initial library: existing store → legacy migration → fresh seed.
* Always persisted before returning so a migrated/seeded library survives a
* reload even if the user never edits. */
function loadLibrary(): Library {
let result: Library | null = null
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const parsed = v.safeParse(LibrarySchema, JSON.parse(raw))
if (parsed.success && parsed.output.plans.length > 0) {
const lib = parsed.output
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
}
} catch {
/* corrupt JSON → fall through to migration / seed */
}
}
if (!result) {
const legacy = localStorage.getItem(LEGACY_KEY)
if (legacy != null) {
const p = newStoredPlan(legacy)
localStorage.removeItem(LEGACY_KEY)
result = { version: 1, activeId: p.id, plans: [p] }
} else {
const seed = newStoredPlan(SAMPLE_PLAN)
result = { version: 1, activeId: seed.id, plans: [seed] }
}
}
save(result)
return result
}
/** /**
* Authoring state for a single Macroplan: the TOML source (autosaved to * Owns the Library — the collection of named Macroplans in localStorage
* localStorage), the parsed Plan, and the current parse error. The last * (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
* successfully-parsed Plan keeps rendering through transient typos (F3). * error, with the last good render kept through transient typos, F3) plus the
* switch / create / delete operations over the library.
*/ */
export function useMacroplan() { export function useMacroplan() {
const source = ref(localStorage.getItem(STORAGE_KEY) ?? SAMPLE_PLAN) const lib = ref<Library>(loadLibrary())
const lastGood = ref<Plan | null>(null) const lastGood = shallowRef<Plan | null>(null)
const active = computed<StoredPlan>(
() => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0],
)
const source = computed<string>({
get: () => active.value.source,
set: (v) => {
const p = active.value
p.source = v
const t = titleOf(v)
if (t) p.name = t // refresh the cached label only on a valid parse
},
})
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => { const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
try { try {
@@ -31,20 +120,47 @@ export function useMacroplan() {
{ immediate: true }, { immediate: true },
) )
watch(source, (v) => { // Persist the whole library on any change (best-effort, like the old autosave).
try { watch(lib, (l) => save(l), { deep: true })
localStorage.setItem(STORAGE_KEY, v)
} catch { // Reset last-good to the target plan's current parse (null if it's broken),
/* localStorage may be full or blocked — autosave is best-effort */ // so switching to a broken plan shows its own error/empty state rather than
// the previous plan's grid.
function switchTo(id: string): void {
lib.value.activeId = id
lastGood.value = parsed.value.plan
} }
})
return { return {
source, source,
plan: computed(() => parsed.value.plan ?? lastGood.value), plan: computed(() => parsed.value.plan ?? lastGood.value),
error: computed(() => parsed.value.error), error: computed(() => parsed.value.error),
resetToSample: () => { plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
source.value = SAMPLE_PLAN activeId: computed(() => lib.value.activeId),
selectPlan: (id: string) => {
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
},
newPlan: () => {
// A new plan starts blank. The bundled sample is reserved for a genuinely
// empty library — first run (loadLibrary) or deleting the last plan
// (deletePlan) — and the ≥1-plan invariant keeps the library non-empty
// here, so "+ New" never re-clones the sample over an existing library.
const p = newStoredPlan("")
lib.value.plans.push(p)
switchTo(p.id)
},
deletePlan: (id: string) => {
const idx = lib.value.plans.findIndex((p) => p.id === id)
if (idx === -1) return
const wasActive = lib.value.activeId === id
lib.value.plans.splice(idx, 1)
if (lib.value.plans.length === 0) {
const seed = newStoredPlan(SAMPLE_PLAN)
lib.value.plans.push(seed)
switchTo(seed.id)
} else if (wasActive) {
switchTo(lib.value.plans[Math.max(0, idx - 1)].id)
}
}, },
} }
} }

View File

@@ -1,17 +1,17 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { exportFilename } from './usePngExport' import { exportFilename } from "./usePngExport"
describe('exportFilename', () => { describe("exportFilename", () => {
it('slugifies the plan title into a .png name', () => { it("slugifies the plan title into a .png name", () => {
expect(exportFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.png') expect(exportFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.png")
}) })
it('collapses runs of punctuation/space and trims edge dashes', () => { it("collapses runs of punctuation/space and trims edge dashes", () => {
expect(exportFilename(' Hello, World!! ')).toBe('macroplan-hello-world.png') expect(exportFilename(" Hello, World!! ")).toBe("macroplan-hello-world.png")
}) })
it('falls back to a generic name when the title has no usable characters', () => { it("falls back to a generic name when the title has no usable characters", () => {
expect(exportFilename('')).toBe('macroplan-plan.png') expect(exportFilename("")).toBe("macroplan-plan.png")
expect(exportFilename('—— ··')).toBe('macroplan-plan.png') expect(exportFilename("—— ··")).toBe("macroplan-plan.png")
}) })
}) })

View File

@@ -1,15 +1,19 @@
import { ref } from 'vue' import { ref } from "vue"
type Toast = { kind: 'ok' | 'err'; text: string } | null type Toast = { kind: "ok" | "err"; text: string } | null
/** Lowercase, dash-collapsed slug of a plan title — no extension, no fallback. */
export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
/** Slugified, stable download name derived from the plan title. */ /** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string { export function exportFilename(title: string): string {
const slug = title return `macroplan-${slugify(title) || "plan"}.png`
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `macroplan-${slug || 'plan'}.png`
} }
/** /**
@@ -24,26 +28,26 @@ export function usePngExport() {
const toast = ref<Toast>(null) const toast = ref<Toast>(null)
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
function flash(kind: 'ok' | 'err', text: string) { function flash(kind: "ok" | "err", text: string) {
toast.value = { kind, text } toast.value = { kind, text }
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer)
timer = setTimeout(() => (toast.value = null), 3000) timer = setTimeout(() => (toast.value = null), 3000)
} }
async function render(el: HTMLElement): Promise<Blob> { async function render(el: HTMLElement): Promise<Blob> {
const { toBlob } = await import('html-to-image') const { toBlob } = await import("html-to-image")
// `exporting` expands the wrapper to max-content and un-clips the grid so the // `exporting` expands the wrapper to max-content and un-clips the grid so the
// whole timeline is captured even when it scrolls horizontally on screen. // whole timeline is captured even when it scrolls horizontally on screen.
el.classList.add('exporting') el.classList.add("exporting")
// reading layout flushes the class's style changes before we measure // reading layout flushes the class's style changes before we measure
const width = el.scrollWidth const width = el.scrollWidth
const height = el.scrollHeight const height = el.scrollHeight
try { try {
const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true }) const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true })
if (!blob) throw new Error('renderer returned no image') if (!blob) throw new Error("renderer returned no image")
return blob return blob
} finally { } finally {
el.classList.remove('exporting') el.classList.remove("exporting")
} }
} }
@@ -53,29 +57,29 @@ export function usePngExport() {
try { try {
// ClipboardItem accepts a Promise<Blob>, so the async render stays inside // ClipboardItem accepts a Promise<Blob>, so the async render stays inside
// the user-gesture window — Safari rejects a write started after an await. // the user-gesture window — Safari rejects a write started after an await.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })]) await navigator.clipboard.write([new ClipboardItem({ "image/png": render(el) })])
flash('ok', 'PNG copied to clipboard') flash("ok", "PNG copied to clipboard")
} catch { } catch {
flash('err', 'Couldnt copy — use Download instead') flash("err", "Couldnt copy — use Download instead")
} finally { } finally {
busy.value = false busy.value = false
} }
} }
async function downloadPng(el?: HTMLElement | null, filename = 'macroplan.png') { async function downloadPng(el?: HTMLElement | null, filename = "macroplan.png") {
if (!el || busy.value) return if (!el || busy.value) return
busy.value = true busy.value = true
try { try {
const blob = await render(el) const blob = await render(el)
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement("a")
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
flash('ok', 'PNG downloaded') flash("ok", "PNG downloaded")
} catch { } catch {
flash('err', 'Export failed — please retry') flash("err", "Export failed — please retry")
} finally { } finally {
busy.value = false busy.value = false
} }

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest"
import { sourceFilename } from "./useSourceExport"
describe("sourceFilename", () => {
it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.toml")
})
it("falls back to a generic name when the title has no usable characters", () => {
expect(sourceFilename("")).toBe("macroplan-plan.toml")
expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
})
})

View File

@@ -0,0 +1,17 @@
import { slugify } from "./usePngExport"
/** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string {
return `macroplan-${slugify(title) || "plan"}.toml`
}
/** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void {
const blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

View File

@@ -1,7 +1,10 @@
// Default Macroplan shown on first load — exercises every state: // Default Macroplan shown on first load — exercises every state:
// on-time delivery, late delivery with slips, in-flight (green/orange/red), // 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. // 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. # Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week. # Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
@@ -32,7 +35,7 @@ learning = "Vendor lead time was the real constraint — derisk vendors up front
name = "Dashboard" name = "Dashboard"
start = 2026-06-01 start = 2026-06-01
original = 2026-06-08 # already past 'now' and undelivered → overdue original = 2026-06-08 # already past 'now' and undelivered → overdue
status = "red" status = "off-track"
note = "No recovery plan yet — needs an owner." note = "No recovery plan yet — needs an owner."
[[feature]] [[feature]]
@@ -40,14 +43,14 @@ name = "Search"
start = 2026-06-08 start = 2026-06-08
original = 2026-06-22 original = 2026-06-22
reestimates = [2026-07-06] # re-estimated once, still in flight → △ reestimates = [2026-07-06] # re-estimated once, still in flight → △
status = "orange" status = "at-risk"
note = "Third-party search API is flaky; spike a fallback." note = "Third-party search API is flaky; spike a fallback."
[[feature]] [[feature]]
name = "Notifications" name = "Notifications"
start = 2026-06-22 start = 2026-06-22
original = 2026-07-06 original = 2026-07-06
status = "green" status = "on-track"
[[milestone]] [[milestone]]
name = "Code freeze" name = "Code freeze"

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue' import { createApp } from "vue"
import './style.css' import "./style.css"
import App from './App.vue' import App from "./App.vue"
createApp(App).mount('#app') createApp(App).mount("#app")

View File

@@ -1,16 +1,54 @@
import { parse as parseToml } from 'smol-toml' import * as v from "valibot"
import { toYmd } from './week' import { parse as parseToml } from "smol-toml"
import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types' import { toYmd } from "./week"
import type { RawPlan, StatusLevel } from "./types"
/** Thrown for any malformed source — message is safe to show the author. */ /** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error { export class PlanParseError extends Error {
constructor(message: string) { constructor(message: string) {
super(message) super(message)
this.name = 'PlanParseError' this.name = "PlanParseError"
} }
} }
const STATUSES: StatusLevel[] = ['green', 'orange', 'red'] 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. */ /** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan { export function parseMacroplan(source: string): RawPlan {
@@ -21,67 +59,94 @@ export function parseMacroplan(source: string): RawPlan {
throw new PlanParseError(e instanceof Error ? e.message : String(e)) throw new PlanParseError(e instanceof Error ? e.message : String(e))
} }
const features = asBlocks(data.feature, 'feature').map(parseFeature) checkVersion(data.macroplan_version)
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
const features = asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
)
requireUniqueFeatureNames(features)
return { return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan', title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? toYmdOr('plan', 'start', data.start) : undefined, start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? toYmdOr('plan', 'end', data.end) : undefined, end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features, features,
milestones, milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
),
} }
} }
function asBlocks(value: unknown, key: string): Record<string, unknown>[] { /** 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 (value == null) return []
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`) throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
} }
return value as Record<string, unknown>[] return value
} }
function parseFeature(f: Record<string, unknown>, i: number): RawFeature { /** "feature \"Payments\"" when the block carries a name, else "feature #2". */
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}` function blockWhere(kind: string, block: unknown, i: number): string {
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``) const name =
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`) block != null && typeof block === "object" && "name" in block
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`) ? (block as { name: unknown }).name
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) { : undefined
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`) return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
}
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 { /** Validate `value` against `schema`, raising a contextual PlanParseError. */
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}` function check<S extends v.GenericSchema>(
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``) schema: S,
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`) value: unknown,
if (m.requires != null && !Array.isArray(m.requires)) { where: string,
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`) field?: string,
} ): v.InferOutput<S> {
return { const result = v.safeParse(schema, value)
name: String(m.name), if (!result.success) {
week: toYmdOr(where, 'week', m.week), throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
requires: ((m.requires as unknown[]) ?? []).map(String),
} }
return result.output
} }
function toYmdOr(where: string, field: string, value: unknown): string { type Issue = {
try { readonly message: string
return toYmd(value) readonly received: string
} catch { readonly path?: ReadonlyArray<{ readonly key: unknown }>
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
} }
/** 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

@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { parseMacroplan, PlanParseError } from './parse' import exampleToml from "../../docs/macroplan.example.toml?raw"
import { buildPlan } from './plan' import { parseMacroplan, PlanParseError } from "./parse"
import { mondayOf, weekRange } from './week' import { buildPlan } from "./plan"
import type { FeatureRow } from './types' import { mondayOf, weekRange } from "./week"
import { SAMPLE_PLAN } from '../data/sample' import type { FeatureRow } from "./types"
import { SAMPLE_PLAN } from "../data/sample"
const TODAY = '2026-06-17' // a Wednesday → week of Mon 2026-06-15 const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
function rowOf(source: string, name: string): FeatureRow { function rowOf(source: string, name: string): FeatureRow {
const plan = buildPlan(parseMacroplan(source), TODAY) const plan = buildPlan(parseMacroplan(source), TODAY)
@@ -14,170 +15,209 @@ function rowOf(source: string, name: string): FeatureRow {
return row return row
} }
describe('week math', () => { describe("week math", () => {
it('snaps any day to its Monday', () => { it("snaps any day to its Monday", () => {
expect(mondayOf('2026-06-17')).toBe('2026-06-15') // Wed → Mon expect(mondayOf("2026-06-17")).toBe("2026-06-15") // Wed → Mon
expect(mondayOf('2026-06-15')).toBe('2026-06-15') // Mon → Mon expect(mondayOf("2026-06-15")).toBe("2026-06-15") // Mon → Mon
expect(mondayOf('2026-06-21')).toBe('2026-06-15') // Sun → that week's Mon expect(mondayOf("2026-06-21")).toBe("2026-06-15") // Sun → that week's Mon
expect(mondayOf('2026-06-01')).toBe('2026-06-01') // Mon expect(mondayOf("2026-06-01")).toBe("2026-06-01") // Mon
}) })
it('builds an inclusive contiguous Monday range', () => { it("builds an inclusive contiguous Monday range", () => {
expect(weekRange('2026-06-01', '2026-06-22')).toEqual([ expect(weekRange("2026-06-01", "2026-06-22")).toEqual([
'2026-06-01', "2026-06-01",
'2026-06-08', "2026-06-08",
'2026-06-15', "2026-06-15",
'2026-06-22', "2026-06-22",
]) ])
}) })
}) })
describe('F2 — on-time / late classification (ADR-0001)', () => { describe("F2 — on-time / late classification (ADR-0001)", () => {
const base = (extra: string) => const base = (extra: string) =>
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n` `[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n`
it('delivered on the Original Estimate week → ◉, no ◯, onTime true', () => { it("delivered on the Original Estimate week → ◉, no ◯, onTime true", () => {
const r = rowOf(base('delivered = 2026-06-15'), 'X') const r = rowOf(base("delivered = 2026-06-15"), "X")
expect(r.onTime).toBe(true) expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time']) expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe('2026-06-15') expect(r.markers[0].week).toBe("2026-06-15")
}) })
it('delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery', () => { it("delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery", () => {
const r = rowOf(base('delivered = 2026-06-08'), 'X') const r = rowOf(base("delivered = 2026-06-08"), "X")
expect(r.onTime).toBe(true) expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time']) expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe('2026-06-08') expect(r.markers[0].week).toBe("2026-06-08")
expect(r.barEndWeek).toBe('2026-06-08') // no ◯ dangling in the future expect(r.barEndWeek).toBe("2026-06-08") // no ◯ dangling in the future
}) })
it('delivered after the Original Estimate → ▲ late, ◯ baseline preserved', () => { it("delivered after the Original Estimate → ▲ late, ◯ baseline preserved", () => {
const r = rowOf(base('delivered = 2026-06-29'), 'X') const r = rowOf(base("delivered = 2026-06-29"), "X")
expect(r.onTime).toBe(false) expect(r.onTime).toBe(false)
const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort() const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort()
expect(kinds).toEqual(['delivered-late@2026-06-29', 'original@2026-06-15']) expect(kinds).toEqual(["delivered-late@2026-06-29", "original@2026-06-15"])
expect(r.barEndWeek).toBe('2026-06-29') expect(r.barEndWeek).toBe("2026-06-29")
}) })
it('late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)', () => { it("late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)", () => {
const r = rowOf( const r = rowOf(base("reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20"), "X")
base('reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20'),
'X',
)
expect(r.onTime).toBe(false) // 07-20 > original 06-15, regardless of the 07-13 re-estimate expect(r.onTime).toBe(false) // 07-20 > original 06-15, regardless of the 07-13 re-estimate
expect(r.slipCount).toBe(2) expect(r.slipCount).toBe(2)
const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => { const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => {
;(acc[m.kind] ??= []).push(m.week) ;(acc[m.kind] ??= []).push(m.week)
return acc return acc
}, {}) }, {})
expect(byKind.original).toEqual(['2026-06-15']) expect(byKind.original).toEqual(["2026-06-15"])
expect(byKind.reestimate?.sort()).toEqual(['2026-06-29', '2026-07-13']) expect(byKind.reestimate?.sort()).toEqual(["2026-06-29", "2026-07-13"])
expect(byKind['delivered-late']).toEqual(['2026-07-20']) expect(byKind["delivered-late"]).toEqual(["2026-07-20"])
}) })
it('in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate', () => { it("in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate", () => {
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "red"'), 'X') const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), "X")
expect(r.onTime).toBeNull() expect(r.onTime).toBeNull()
expect(r.delivered).toBe(false) expect(r.delivered).toBe(false)
expect(r.status).toBe('red') expect(r.status).toBe("off-track")
expect(r.barEndWeek).toBe('2026-06-29') // furthest open estimate expect(r.barEndWeek).toBe("2026-06-29") // furthest open estimate
expect(r.markers.some((m) => m.kind === 'original')).toBe(true) expect(r.markers.some((m) => m.kind === "original")).toBe(true)
}) })
}) })
describe('bar extent relative to now', () => { describe("bar extent relative to now", () => {
const feat = (extra: string) => const feat = (extra: string) =>
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-08\n${extra}\n` `[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-08\n${extra}\n`
it('extends an overdue undelivered bar to the now week, keeping ◯ at the estimate', () => { it("extends an overdue undelivered bar to the now week, keeping ◯ at the estimate", () => {
// original 2026-06-08 is before now (week of 2026-06-15) and undelivered → overdue // original 2026-06-08 is before now (week of 2026-06-15) and undelivered → overdue
const r = rowOf(feat('status = "red"'), 'X') const r = rowOf(feat('status = "off-track"'), "X")
expect(r.delivered).toBe(false) expect(r.delivered).toBe(false)
expect(r.barEndWeek).toBe('2026-06-15') // runs up to now, past the 06-08 estimate expect(r.barEndWeek).toBe("2026-06-15") // runs up to now, past the 06-08 estimate
expect(r.markers.find((m) => m.kind === 'original')?.week).toBe('2026-06-08') expect(r.markers.find((m) => m.kind === "original")?.week).toBe("2026-06-08")
}) })
it('does not extend a delivered bar past its delivery, even when now is later', () => { it("does not extend a delivered bar past its delivery, even when now is later", () => {
const r = rowOf(feat('delivered = 2026-06-08'), 'X') const r = rowOf(feat("delivered = 2026-06-08"), "X")
expect(r.barEndWeek).toBe('2026-06-08') // ends at delivery, not at the now week expect(r.barEndWeek).toBe("2026-06-08") // ends at delivery, not at the now week
}) })
it('ends an on-track undelivered bar at the future estimate (no extension back to now)', () => { it("ends an on-track undelivered bar at the future estimate (no extension back to now)", () => {
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', 'X') const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', "X")
expect(r.barEndWeek).toBe('2026-07-06') expect(r.barEndWeek).toBe("2026-07-06")
}) })
}) })
describe('plan derivation', () => { describe("plan derivation", () => {
it('derives a contiguous week range and places the now line', () => { it("derives a contiguous week range and places the now line", () => {
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY) const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
expect(plan.weeks[0]).toBe('2026-05-25') // authored span start (lead-in before earliest Feature) expect(plan.weeks[0]).toBe("2026-05-25") // authored span start (lead-in before earliest Feature)
expect(plan.weeks.at(-1)).toBe('2026-08-03') // authored span end (trailing past last marker) expect(plan.weeks.at(-1)).toBe("2026-08-03") // authored span end (trailing past last marker)
// contiguous, weekly // contiguous, weekly
expect(plan.weeks).toContain('2026-06-29') expect(plan.weeks).toContain("2026-06-29")
expect(plan.nowWeek).toBe('2026-06-15') expect(plan.nowWeek).toBe("2026-06-15")
expect(plan.nowInRange).toBe(true) expect(plan.nowInRange).toBe(true)
}) })
it('flags a Milestones unmet required Features (undelivered or delivered after the milestone)', () => { it("flags a Milestones unmet required Features (undelivered or delivered after the milestone)", () => {
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY) const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
const mvp = plan.milestones.find((m) => m.name === 'MVP go-live')! const mvp = plan.milestones.find((m) => m.name === "MVP go-live")!
expect(mvp.week).toBe('2026-07-06') expect(mvp.week).toBe("2026-07-06")
// Auth delivered 06-15 (met); Payments delivered 07-20 > 07-06 (unmet); Dashboard undelivered (unmet) // Auth delivered 06-15 (met); Payments delivered 07-20 > 07-06 (unmet); Dashboard undelivered (unmet)
expect(mvp.unmet.sort()).toEqual(['Dashboard', 'Payments']) expect(mvp.unmet.sort()).toEqual(["Dashboard", "Payments"])
}) })
}) })
describe('authored plan span (start / end)', () => { describe("authored plan span (start / end)", () => {
const body = '[[feature]]\nname="X"\nstart=2026-06-08\noriginal=2026-06-15\ndelivered=2026-06-15\n' const body =
'[[feature]]\nname="X"\nstart=2026-06-08\noriginal=2026-06-15\ndelivered=2026-06-15\n'
it('extends the range earlier to `start` and later to `end`', () => { it("extends the range earlier to `start` and later to `end`", () => {
const plan = buildPlan(parseMacroplan(`start = 2026-06-01\nend = 2026-06-29\n${body}`), TODAY) const plan = buildPlan(parseMacroplan(`start = 2026-06-01\nend = 2026-06-29\n${body}`), TODAY)
expect(plan.weeks).toEqual(weekRange('2026-06-01', '2026-06-29')) expect(plan.weeks).toEqual(weekRange("2026-06-01", "2026-06-29"))
expect(plan.weeks[0]).toBe('2026-06-01') // before the earliest Feature week (06-08) expect(plan.weeks[0]).toBe("2026-06-01") // before the earliest Feature week (06-08)
expect(plan.weeks.at(-1)).toBe('2026-06-29') // after the last marker (06-15) expect(plan.weeks.at(-1)).toBe("2026-06-29") // after the last marker (06-15)
}) })
it('snaps authored bounds to their Monday', () => { it("snaps authored bounds to their Monday", () => {
const plan = buildPlan(parseMacroplan(`start = 2026-06-03\nend = 2026-06-24\n${body}`), TODAY) const plan = buildPlan(parseMacroplan(`start = 2026-06-03\nend = 2026-06-24\n${body}`), TODAY)
expect(plan.weeks[0]).toBe('2026-06-01') // Wed 06-03 → Mon 06-01 expect(plan.weeks[0]).toBe("2026-06-01") // Wed 06-03 → Mon 06-01
expect(plan.weeks.at(-1)).toBe('2026-06-22') // Wed 06-24 → Mon 06-22 expect(plan.weeks.at(-1)).toBe("2026-06-22") // Wed 06-24 → Mon 06-22
}) })
it('only extends — a marker outside the authored bounds is never clipped', () => { it("only extends — a marker outside the authored bounds is never clipped", () => {
// end 06-08 is before the Feature's 06-15 delivery → the range still includes it // end 06-08 is before the Feature's 06-15 delivery → the range still includes it
const plan = buildPlan(parseMacroplan(`start = 2026-06-08\nend = 2026-06-08\n${body}`), TODAY) const plan = buildPlan(parseMacroplan(`start = 2026-06-08\nend = 2026-06-08\n${body}`), TODAY)
expect(plan.weeks[0]).toBe('2026-06-08') expect(plan.weeks[0]).toBe("2026-06-08")
expect(plan.weeks.at(-1)).toBe('2026-06-15') expect(plan.weeks.at(-1)).toBe("2026-06-15")
}) })
it('renders an empty plan across the authored bounds when there are no Features', () => { it("renders an empty plan across the authored bounds when there are no Features", () => {
const plan = buildPlan(parseMacroplan('start = 2026-06-01\nend = 2026-06-22\n'), TODAY) const plan = buildPlan(parseMacroplan("start = 2026-06-01\nend = 2026-06-22\n"), TODAY)
expect(plan.rows).toHaveLength(0) expect(plan.rows).toHaveLength(0)
expect(plan.weeks).toEqual(weekRange('2026-06-01', '2026-06-22')) expect(plan.weeks).toEqual(weekRange("2026-06-01", "2026-06-22"))
}) })
it('rejects a non-date span bound', () => { it("rejects a non-date span bound", () => {
expect(() => parseMacroplan('start = 123\n')).toThrow(/start/) expect(() => parseMacroplan("start = 123\n")).toThrow(/start/)
}) })
}) })
describe('parse validation', () => { describe("parse validation", () => {
it('rejects a feature missing its Original Estimate', () => { it("rejects a feature missing its Original Estimate", () => {
expect(() => parseMacroplan('[[feature]]\nname = "A"\nstart = 2026-06-01\n')).toThrow( expect(() => parseMacroplan('[[feature]]\nname = "A"\nstart = 2026-06-01\n')).toThrow(
PlanParseError, PlanParseError,
) )
}) })
it('rejects an invalid status', () => { it("rejects an invalid status", () => {
expect(() => expect(() =>
parseMacroplan('[[feature]]\nname="A"\nstart=2026-06-01\noriginal=2026-06-08\nstatus="blue"\n'), parseMacroplan(
'[[feature]]\nname="A"\nstart=2026-06-01\noriginal=2026-06-08\nstatus="blue"\n',
),
).toThrow(/status/) ).toThrow(/status/)
}) })
it('parses the bundled sample without error', () => { it("parses the bundled sample without error", () => {
const raw = parseMacroplan(SAMPLE_PLAN) const raw = parseMacroplan(SAMPLE_PLAN)
expect(raw.features).toHaveLength(5) expect(raw.features).toHaveLength(5)
expect(raw.milestones).toHaveLength(3) 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)
})
})

View File

@@ -1,5 +1,5 @@
import { mondayOf, weekRange, type WeekId } from './week' import { mondayOf, weekRange, type WeekId } from "./week"
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from './types' import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from "./types"
/** /**
* Derive the render-ready Plan from the raw model (component C2). * Derive the render-ready Plan from the raw model (component C2).
@@ -17,7 +17,7 @@ export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan
const row = rows.find((r) => r.name === name) const row = rows.find((r) => r.name === name)
if (!row) return true // references an unknown Feature → can't be met if (!row) return true // references an unknown Feature → can't be met
const delivery = row.markers.find( const delivery = row.markers.find(
(x) => x.kind === 'delivered-on-time' || x.kind === 'delivered-late', (x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
) )
return !delivery || delivery.week > week // undelivered, or delivered after the milestone return !delivery || delivery.week > week // undelivered, or delivered after the milestone
}) })
@@ -56,14 +56,14 @@ function buildRow(f: RawFeature, nowWeek: WeekId): FeatureRow {
const onTime = delivered ? deliveredWeek! <= originalWeek : null const onTime = delivered ? deliveredWeek! <= originalWeek : null
const markers: Marker[] = [] const markers: Marker[] = []
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: 'reestimate' }) for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: "reestimate" })
if (delivered) { if (delivered) {
markers.push({ week: deliveredWeek!, kind: onTime ? 'delivered-on-time' : 'delivered-late' }) markers.push({ week: deliveredWeek!, kind: onTime ? "delivered-on-time" : "delivered-late" })
} }
// The Original Estimate `◯` stands unless an on-time/early delivery already // The Original Estimate `◯` stands unless an on-time/early delivery already
// occupies (or precedes) it — then the delivery marker speaks for it. // occupies (or precedes) it — then the delivery marker speaks for it.
if (!(delivered && onTime)) { if (!(delivered && onTime)) {
markers.push({ week: originalWeek, kind: 'original' }) markers.push({ week: originalWeek, kind: "original" })
} }
const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b)) const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))

View File

@@ -1,6 +1,6 @@
import type { WeekId } from './week' import type { WeekId } from "./week"
export type StatusLevel = 'green' | 'orange' | 'red' export type StatusLevel = "on-track" | "at-risk" | "off-track"
// ── Raw model: as authored, after TOML parse + validation, before derivation ── // ── Raw model: as authored, after TOML parse + validation, before derivation ──
@@ -32,10 +32,10 @@ export interface RawPlan {
// ── Derived model: render-ready (C2 output) ── // ── Derived model: render-ready (C2 output) ──
export type MarkerKind = export type MarkerKind =
| 'original' // ◯ Original Estimate, not yet delivered | "original" // ◯ Original Estimate, not yet delivered
| 'reestimate' // △ a slip to a later week | "reestimate" // △ a slip to a later week
| 'delivered-on-time' // ◉ delivered on/before the Original Estimate | "delivered-on-time" // ◉ delivered on/before the Original Estimate
| 'delivered-late' // ▲ delivered after the Original Estimate | "delivered-late" // ▲ delivered after the Original Estimate
export interface Marker { export interface Marker {
week: WeekId week: WeekId

View File

@@ -10,14 +10,14 @@ export type WeekId = string // 'yyyy-mm-dd', always a Monday
*/ */
export function toYmd(value: unknown): string { export function toYmd(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10) if (value instanceof Date) return value.toISOString().slice(0, 10)
if (typeof value === 'string') return value.slice(0, 10) if (typeof value === "string") return value.slice(0, 10)
throw new Error(`expected a date, got ${JSON.stringify(value)}`) 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 // Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
// timezone boundary. // timezone boundary.
function utcNoon(ymd: string): Date { function utcNoon(ymd: string): Date {
const [y, m, d] = ymd.split('-').map(Number) const [y, m, d] = ymd.split("-").map(Number)
return new Date(Date.UTC(y, m - 1, d, 12)) return new Date(Date.UTC(y, m - 1, d, 12))
} }
@@ -51,9 +51,9 @@ export function weekRange(start: WeekId, end: WeekId): WeekId[] {
/** Short column label for a week, e.g. "Jun 15". */ /** Short column label for a week, e.g. "Jun 15". */
export function weekLabel(week: WeekId): string { export function weekLabel(week: WeekId): string {
return utcNoon(week).toLocaleDateString('en-US', { return utcNoon(week).toLocaleDateString("en-US", {
month: 'short', month: "short",
day: '2-digit', day: "2-digit",
timeZone: 'UTC', timeZone: "UTC",
}) })
} }

View File

@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite"
import vue from '@vitejs/plugin-vue' import vue from "@vitejs/plugin-vue"
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({