Compare commits

...

39 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
42 changed files with 2641 additions and 688 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,6 +1,6 @@
# Macroplan
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*.
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
@@ -33,7 +33,7 @@ A free-text takeaway captured against a Feature once it is delivered — what th
_Avoid_: retro note, lesson, postmortem
**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
**Week**:
@@ -48,6 +48,10 @@ _Avoid_: today marker, cursor
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
_Avoid_: workspace, project, file list
**Format version**:
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
_Avoid_: schema version, file version, app version
## Symbols
- `┣` start of a Feature's bar

330
DESIGN.md
View File

@@ -1,133 +1,37 @@
# 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.
---
## 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 ≤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)
## House of Quality
```tikz
% =====================================================================
% 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}
% Toggles — flip before \begin{qfdhouse} to hide sections.
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\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\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\newif\ifqfdshowtitle \qfdshowtitletrue % title block above the roof
\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}
% Dimensions — override before \begin{qfdhouse} to resize.
\def\qfdNW{5} % number of WHATs (rows)
\def\qfdNH{5} % number of HOWs (columns)
\def\qfdWhatW{4.0} % width of WHATs column
\def\qfdImpW{0.9} % width of importance column
\def\qfdCmpW{3} % width of perception zone
\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\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product}
\def\qfdAltOneLabel{Our product} % highlighted in legend
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\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{
qfdthin/.style ={line width=0.35pt},
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/M/.style={qfdmod},
qfdrel/W/.style={qfdweak},
% Three perception-zone alternatives. Index 1 is emphasised.
qfdalt1mk/.style={circle, draw, fill=black,
minimum size=6pt, inner sep=0pt, line width=1pt},
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},
}
% --- Grid lines for every zone. ---
\newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\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
}
% --- Roof: diagonal grid + named coordinates (C-i-j) for correlations. ---
\newcommand{\qfdDrawRoof}{%
\ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {%
@@ -226,6 +142,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
\fi
}
% --- Perception scale 0..5 + poor/excellent endpoints + zone title. ---
\newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive
\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
}
% --- Importance title (left) and WHATs title (header band). ---
\newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance
\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};
}
% --- 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}{%
\begin{scope}[qfdmed]
\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}
}
% --- Legend on the right (Relations / Correlations / Evaluation). ---
\newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend
\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)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
% Relations
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{\qfdRelTitle};
\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[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
% Correlations (roof)
\ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{\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.75) {{$-\!-$}\quad very negative};
\fi \fi
% Evaluation (3 alternatives)
\ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{%
-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
}
% --- The environment users wrap their content in. ---
\newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
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
\qfdDrawScale
\qfdDrawZoneTitles
\qfdDrawTitle
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
\def\qfdNW{5}
\def\qfdNH{7}
\def\qfdWhatW{4.6}
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
\def\qfdWhatsTitle{Goals (WHATs)}
\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
\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
\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,
3/{$\leq$1s}/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 %**.
---
## 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
| 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 |
| 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 |
| 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
| 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 | — |
| 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 |
| 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 |
### 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.
- **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
- **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.
- **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
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*.
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.
@@ -31,7 +31,8 @@ Dashboard ┣━━━━━◯ 🔴 n
## Documentation
| Document | What it covers |
|----------|----------------|
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [docs/format.md](docs/format.md) | **The Macroplan TOML format (v1)** — the portable, tool-independent schema: fields, value types, the `macroplan_version` marker, and the render semantics a consumer must honor. Ships a [JSON Schema](public/macroplan.schema.json) and a [reference `.toml`](docs/macroplan.example.toml). |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |

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

@@ -29,12 +29,14 @@
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`
@@ -44,17 +46,17 @@ Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename +
Create `src/composables/useSourceExport.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { sourceFilename } from './useSourceExport'
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')
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')
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")
})
})
```
@@ -74,31 +76,31 @@ export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.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`
return `macroplan-${slugify(title) || "plan"}.png`
}
```
- [ ] **Step 4: Create `useSourceExport.ts`**
```ts
import { slugify } from './usePngExport'
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`
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 blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
@@ -125,10 +127,12 @@ git commit -m "feat(export): add .toml source download and shared slugify helper
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`
@@ -141,36 +145,36 @@ 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'
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' },
{ 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')
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 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'])
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"])
})
})
```
@@ -184,7 +188,7 @@ Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
```vue
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
@@ -198,7 +202,7 @@ const emit = defineEmits<{
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>
@@ -245,10 +249,12 @@ git commit -m "feat(ui): add the plan switcher dropdown"
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
@@ -259,7 +265,7 @@ Turn the single-source composable into the Library owner: storage + migration +
- `selectPlan(id: string): void`
- `newPlan(): void`
- `deletePlan(id: string): void`
- `resetToSample(): void` *(temporary — removed in Task 4)*
- `resetToSample(): void` _(temporary — removed in Task 4)_
- [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
@@ -267,18 +273,18 @@ 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'
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'
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', () => {
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)
@@ -286,59 +292,59 @@ describe('useMacroplan — load & migration', () => {
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', () => {
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(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')
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', () => {
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 }],
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')
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 () => {
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')
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 () => {
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
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe('useMacroplan — CRUD', () => {
it('newPlan appends a sample plan and activates it', () => {
describe("useMacroplan — CRUD", () => {
it("newPlan appends a sample plan and activates it", () => {
const m = useMacroplan()
const firstId = m.activeId.value
m.newPlan()
@@ -347,7 +353,7 @@ describe('useMacroplan — CRUD', () => {
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('deletePlan removes the active plan and re-points to the preceding one', () => {
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
@@ -356,14 +362,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id)
})
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
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 () => {
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()
@@ -391,14 +397,14 @@ Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not e
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'
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'
const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
export interface StoredPlan {
id: string
@@ -423,7 +429,7 @@ function titleOf(source: string): string | null {
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
@@ -578,10 +584,12 @@ git commit -m "feat(model): make useMacroplan own a library of named plans"
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`**
@@ -601,13 +609,13 @@ 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'
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()
@@ -616,7 +624,7 @@ const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {
@@ -659,7 +667,11 @@ function confirmDelete() {
>
Download
</button>
<button class="btn btn-ghost btn-sm" title="Delete this plan" @click="confirmingDelete = true">
<button
class="btn btn-ghost btn-sm"
title="Delete this plan"
@click="confirmingDelete = true"
>
🗑
</button>
</header>
@@ -737,6 +749,7 @@ Expected: PASS — all suites (existing + the new filename, switcher, and librar
- [ ] **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.
@@ -762,6 +775,7 @@ git commit -m "feat(ui): wire the plan switcher, delete confirm and .toml downlo
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`
@@ -770,7 +784,6 @@ Record the new `Library` term in the ubiquitous language and flip the README now
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
@@ -829,6 +842,7 @@ 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.

View File

@@ -2,7 +2,7 @@
**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."*
**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
@@ -13,16 +13,18 @@ This adds the library: keep several named Macroplans, switch between them, creat
## Scope
**In:**
- Multiple named Macroplans persisted in localStorage.
- A header dropdown to switch the active plan.
- Create a new plan (seeded from the bundled sample).
- 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.
- 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).
@@ -47,7 +49,7 @@ macroplan:library → {
```
| 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. |
@@ -77,17 +79,17 @@ Holds `plans` and `activeId`; persists the whole library to `macroplan:library`
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 sample-seeded plan and switch to it. |
| `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()` (seeded from the sample) replaces it.
`resetToSample` is **removed**`newPlan()` (a blank page) replaces it.
### `PlanSwitcher.vue` (new) — presentational
@@ -118,7 +120,7 @@ edit TOML ─► source (active plan) ─► autosave whole library ─► local
└─► 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 sample plan ─► selectPlan(newId)
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
```
@@ -133,25 +135,28 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
## 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 sample plan and makes it active.
- `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.
- **`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

View File

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

457
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
smol-toml:
specifier: ^1.6.1
version: 1.6.1
valibot:
specifier: ^1.4.1
version: 1.4.1(typescript@6.0.3)
vue:
specifier: ^3.5.34
version: 3.5.38(typescript@6.0.3)
@@ -42,6 +45,12 @@ importers:
happy-dom:
specifier: ^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:
specifier: ^4.3.1
version: 4.3.1
@@ -118,6 +127,250 @@ packages:
'@oxc-project/types@0.133.0':
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':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -801,6 +1054,32 @@ packages:
oniguruma-to-es@4.3.6:
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:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -927,6 +1206,10 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
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:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
@@ -960,6 +1243,14 @@ packages:
unist-util-visit@5.1.0:
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:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -1177,6 +1468,120 @@ snapshots:
'@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':
optional: true
@@ -1803,6 +2208,52 @@ snapshots:
regex: 6.1.0
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: {}
path-browserify@1.0.1: {}
@@ -1932,6 +2383,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.1.0: {}
tinyrainbow@3.1.0: {}
trim-lines@3.0.1: {}
@@ -1966,6 +2419,10 @@ snapshots:
unist-util-is: 6.0.1
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:
dependencies:
'@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" }
}
}
}
}
}

View File

@@ -1,18 +1,18 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
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')>()
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'
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 }
@@ -23,69 +23,67 @@ function mountApp() {
// 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')
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')
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 () => {
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 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 () => {
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 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('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 w.find(".modal-action .btn-error").trigger("click") // Delete
await nextTick()
expect(w.find('.modal-open').exists()).toBe(false)
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 () => {
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)
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)
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 () => {
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 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')
expect(w.text()).toContain("Q3 — Checkout revamp")
})
it('wires the dropdown .toml download to downloadSource with the active source and a .toml name', async () => {
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)
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')
expect(filename).toBe("macroplan-q3-checkout-revamp.toml")
})
})

View File

@@ -1,11 +1,11 @@
<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'
import { ref, computed, watch } 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()
@@ -13,8 +13,30 @@ const { busy, toast, copyPng, downloadPng } = usePngExport()
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',
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {
@@ -40,9 +62,38 @@ function confirmDelete() {
@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>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
:disabled="!plan || busy || !showMacroplan"
title="Copy the rendered plan to the clipboard as a PNG"
@click="copyPng(exportRoot)"
>
@@ -51,7 +102,7 @@ function confirmDelete() {
</button>
<button
class="btn btn-ghost btn-sm"
:disabled="!plan || busy"
:disabled="!plan || busy || !showMacroplan"
title="Download the rendered plan as a PNG"
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
>
@@ -69,7 +120,11 @@ function confirmDelete() {
<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"
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">
Source (TOML)
@@ -77,7 +132,7 @@ function confirmDelete() {
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
</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">
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
<MacroplanGrid :plan="plan" />
@@ -124,4 +179,12 @@ function confirmDelete() {
.export-root.exporting :deep(.macroplan) {
overflow: visible;
}
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
status notes) must wrap to its full length in the capture instead of being cut.
The column width is unchanged, so the milestone band stays aligned. */
.export-root.exporting :deep(.namecell .truncate),
.export-root.exporting :deep(.learncell .note) {
white-space: normal;
overflow: visible;
}
</style>

View File

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

View File

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

View File

@@ -1,52 +1,52 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Plan, FeatureRow, MarkerKind } from '../model/types'
import { weekLabel, type WeekId } from '../model/week'
import { computed } from "vue"
import type { Plan, FeatureRow, MarkerKind } from "../model/types"
import { weekLabel, type WeekId } from "../model/week"
const props = defineProps<{ plan: Plan }>()
type Tone = 'success' | 'warning' | 'error' | 'neutral'
type Tone = "success" | "warning" | "error" | "neutral"
const GLYPH: Record<MarkerKind, string> = {
original: '◯',
reestimate: '△',
'delivered-on-time': '◉',
'delivered-late': '▲',
original: "◯",
reestimate: "△",
"delivered-on-time": "◉",
"delivered-late": "▲",
}
const MARKER_CLASS: Record<MarkerKind, string> = {
original: 'text-base-content/50',
reestimate: 'text-warning',
'delivered-on-time': 'text-success',
'delivered-late': 'text-error',
original: "text-base-content/50",
reestimate: "text-warning",
"delivered-on-time": "text-success",
"delivered-late": "text-error",
}
// When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = {
'delivered-late': 3,
'delivered-on-time': 3,
"delivered-late": 3,
"delivered-on-time": 3,
reestimate: 2,
original: 1,
}
const TONE_TEXT: Record<Tone, string> = {
success: 'text-success',
warning: 'text-warning',
error: 'text-error',
neutral: 'text-primary',
success: "text-success",
warning: "text-warning",
error: "text-error",
neutral: "text-primary",
}
const TONE_BORDER: Record<Tone, string> = {
success: 'border-success',
warning: 'border-warning',
error: 'border-error',
neutral: 'border-primary',
success: "border-success",
warning: "border-warning",
error: "border-error",
neutral: "border-primary",
}
const TONE_DOT: Record<Tone, string> = {
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
neutral: 'bg-base-300',
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
neutral: "bg-base-300",
}
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
const NAME_W = 9 * 16
const NAME_W = 16 * 16
const WK = 3.5 * 16
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 {
if (row.delivered) return row.onTime ? 'success' : 'error'
if (row.status === 'green') return 'success'
if (row.status === 'orange') return 'warning'
if (row.status === 'red') return 'error'
return 'neutral'
if (row.delivered) return row.onTime ? "success" : "error"
if (row.status === "on-track") return "success"
if (row.status === "at-risk") return "warning"
if (row.status === "off-track") return "error"
return "neutral"
}
function statusWord(row: FeatureRow): string {
return row.status === 'green'
? 'on track'
: row.status === 'orange'
? 'at risk'
: row.status === 'red'
? 'blocked'
: 'in flight'
return row.status ? row.status.replace("-", " ") : "in flight"
}
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
@@ -85,7 +79,7 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
interface Cell {
// how far the bar line runs within this cell: none, center→right,
// left→center, or full width
line: 'none' | 'right' | 'left' | 'full'
line: "none" | "right" | "left" | "full"
isStart: boolean
glyph: string
glyphCls: string
@@ -97,17 +91,17 @@ const matrix = computed<Cell[][]>(() =>
weeks.value.map((w) => {
const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell['line'] = 'none'
let line: Cell["line"] = "none"
if (inBar) {
const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek
line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full'
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
}
return {
line,
isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : '',
glyphCls: m ? MARKER_CLASS[m] : '',
glyph: m ? GLYPH[m] : "",
glyphCls: m ? MARKER_CLASS[m] : "",
}
}),
),
@@ -125,9 +119,9 @@ const cols = computed(() =>
function colClass(i: number): string {
const c = cols.value[i]
if (c.isNow) return 'col-now'
if (c.isMilestone) return 'col-ms'
return ''
if (c.isNow) return "col-now"
if (c.isMilestone) return "col-ms"
return ""
}
// Milestone label flags above the axis, greedily stacked onto extra rows so
@@ -140,13 +134,13 @@ const milestoneFlags = computed(() => {
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
return items.map((x) => {
const startX = NAME_W + (x.col - 2) * WK
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : '')
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
const width = text.length * BAND_CHAR + 14
let row = 0
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
rowEnd[row] = startX + width
const title = x.m.unmet.length
? `${x.m.name} — unmet: ${x.m.unmet.join(', ')}`
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
: `${x.m.name} — all required features met`
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
})
@@ -173,7 +167,7 @@ const bandStyle = computed(() => ({
<div class="plan-grid" :style="gridStyle">
<!-- header row -->
<div class="hcell corner">{{ plan.title }}</div>
<div class="hcell corner">Feature</div>
<div
v-for="(c, ci) in cols"
:key="'h-' + c.w"
@@ -203,7 +197,9 @@ const bandStyle = computed(() => ({
<div class="learncell">
<template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span>
<span v-else class="muted">{{
row.onTime ? "delivered on time" : "delivered late"
}}</span>
</template>
<template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
@@ -228,7 +224,7 @@ const bandStyle = computed(() => ({
<style scoped>
.macroplan {
--name-w: 9rem;
--name-w: 16rem;
--wk: 3.5rem;
}
.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">
import { ref, computed, onMounted } from 'vue'
import type { HighlighterCore } from 'shiki/core'
import { ref, computed, onMounted, nextTick } from "vue"
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 emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>()
const popupEl = ref<HTMLElement>()
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 () => {
measure()
// 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).
const [core, engine, toml, theme] = await Promise.all([
import('shiki/core'),
import('shiki/engine/javascript'),
import('shiki/langs/toml.mjs'),
import('shiki/themes/github-light.mjs'),
import("shiki/core"),
import("shiki/engine/javascript"),
import("shiki/langs/toml.mjs"),
import("shiki/themes/github-light.mjs"),
])
highlighter.value = await core.createHighlighterCore({
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(() => {
// 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
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 {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)
}
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() {
if (!textarea.value || !backdrop.value) return
backdrop.value.scrollTop = textarea.value.scrollTop
backdrop.value.scrollLeft = textarea.value.scrollLeft
if (completion.value) position()
}
</script>
@@ -63,7 +202,25 @@ function syncScroll() {
aria-label="Macroplan TOML source"
@input="onInput"
@scroll="syncScroll"
@keydown="onKeydown"
@blur="completion = null"
></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 v-if="error" class="editor-error" role="alert">
<span class="font-semibold">Cant parse:</span> {{ error }}
@@ -95,7 +252,11 @@ function syncScroll() {
padding: 0.9rem 1rem;
font-family: inherit; /* Fira Code, from the global theme */
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;
white-space: pre;
}
@@ -133,6 +294,42 @@ function syncScroll() {
.input::selection {
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 {
padding: 0.6rem 1rem;
font-size: 0.74rem;

View File

@@ -1,33 +1,33 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlanSwitcher from './PlanSwitcher.vue'
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' },
{ 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')
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 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'])
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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
@@ -13,7 +13,7 @@ const emit = defineEmits<{
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>

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

@@ -1,16 +1,16 @@
// @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'
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'
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', () => {
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)
@@ -18,68 +18,80 @@ describe('useMacroplan — load & migration', () => {
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', () => {
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(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')
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', () => {
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 }],
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')
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 () => {
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')
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 () => {
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
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe('useMacroplan — CRUD', () => {
it('newPlan appends a sample plan and activates it', () => {
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(SAMPLE_PLAN)
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', () => {
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
@@ -88,14 +100,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id)
})
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
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 () => {
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()

View File

@@ -1,23 +1,26 @@
import { ref, shallowRef, 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'
import { ref, shallowRef, computed, watch } from "vue"
import * as v from "valibot"
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'
const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
export interface StoredPlan {
id: string
name: string
source: string
}
const StoredPlanSchema = v.object({
id: v.string(),
name: v.string(),
source: v.string(),
})
export type StoredPlan = v.InferOutput<typeof StoredPlanSchema>
interface Library {
version: 1
activeId: string
plans: StoredPlan[]
}
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). */
@@ -30,7 +33,7 @@ function titleOf(source: string): string | null {
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
@@ -50,8 +53,9 @@ function loadLibrary(): Library {
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) {
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 }
}
@@ -137,7 +141,11 @@ export function useMacroplan() {
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
},
newPlan: () => {
const p = newStoredPlan(SAMPLE_PLAN)
// 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)
},

View File

@@ -1,17 +1,17 @@
import { describe, it, expect } from 'vitest'
import { exportFilename } from './usePngExport'
import { describe, it, expect } from "vitest"
import { exportFilename } from "./usePngExport"
describe('exportFilename', () => {
it('slugifies the plan title into a .png name', () => {
expect(exportFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.png')
describe("exportFilename", () => {
it("slugifies the plan title into a .png name", () => {
expect(exportFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.png")
})
it('collapses runs of punctuation/space and trims edge dashes', () => {
expect(exportFilename(' Hello, World!! ')).toBe('macroplan-hello-world.png')
it("collapses runs of punctuation/space and trims edge dashes", () => {
expect(exportFilename(" Hello, World!! ")).toBe("macroplan-hello-world.png")
})
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')
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")
})
})

View File

@@ -1,19 +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, '')
.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`
return `macroplan-${slugify(title) || "plan"}.png`
}
/**
@@ -28,26 +28,26 @@ export function usePngExport() {
const toast = ref<Toast>(null)
let timer: ReturnType<typeof setTimeout> | undefined
function flash(kind: 'ok' | 'err', text: string) {
function flash(kind: "ok" | "err", text: string) {
toast.value = { kind, text }
if (timer) clearTimeout(timer)
timer = setTimeout(() => (toast.value = null), 3000)
}
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
// 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
const width = el.scrollWidth
const height = el.scrollHeight
try {
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
} finally {
el.classList.remove('exporting')
el.classList.remove("exporting")
}
}
@@ -57,29 +57,29 @@ export function usePngExport() {
try {
// ClipboardItem accepts a Promise<Blob>, so the async render stays inside
// the user-gesture window — Safari rejects a write started after an await.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })])
flash('ok', 'PNG copied to clipboard')
await navigator.clipboard.write([new ClipboardItem({ "image/png": render(el) })])
flash("ok", "PNG copied to clipboard")
} catch {
flash('err', 'Couldnt copy — use Download instead')
flash("err", "Couldnt copy — use Download instead")
} finally {
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
busy.value = true
try {
const blob = await render(el)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
flash('ok', 'PNG downloaded')
flash("ok", "PNG downloaded")
} catch {
flash('err', 'Export failed — please retry')
flash("err", "Export failed — please retry")
} finally {
busy.value = false
}

View File

@@ -1,13 +1,13 @@
import { describe, it, expect } from 'vitest'
import { sourceFilename } from './useSourceExport'
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')
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')
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

@@ -1,15 +1,15 @@
import { slugify } from './usePngExport'
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`
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 blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()

View File

@@ -1,7 +1,10 @@
// 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.
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md.
macroplan_version = 1
title = "Q3 — Checkout revamp"
# Optional plan span: pad the plan with lead-in / trailing weeks.
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
@@ -32,7 +35,7 @@ learning = "Vendor lead time was the real constraint — derisk vendors up front
name = "Dashboard"
start = 2026-06-01
original = 2026-06-08 # already past 'now' and undelivered → overdue
status = "red"
status = "off-track"
note = "No recovery plan yet — needs an owner."
[[feature]]
@@ -40,14 +43,14 @@ name = "Search"
start = 2026-06-08
original = 2026-06-22
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."
[[feature]]
name = "Notifications"
start = 2026-06-22
original = 2026-07-06
status = "green"
status = "on-track"
[[milestone]]
name = "Code freeze"

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from "vue"
import "./style.css"
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 { toYmd } from './week'
import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types'
import * as v from "valibot"
import { parse as parseToml } from "smol-toml"
import { toYmd } from "./week"
import type { RawPlan, StatusLevel } from "./types"
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'PlanParseError'
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. */
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))
}
const features = asBlocks(data.feature, 'feature').map(parseFeature)
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
checkVersion(data.macroplan_version)
const features = asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
)
requireUniqueFeatureNames(features)
return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
start: data.start != null ? toYmdOr('plan', 'start', data.start) : undefined,
end: data.end != null ? toYmdOr('plan', 'end', data.end) : undefined,
title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features,
milestones,
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 (!Array.isArray(value)) {
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 {
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
}
if (f.reestimates != null && !Array.isArray(f.reestimates)) {
throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`)
}
return {
name: String(f.name),
start: toYmdOr(where, 'start', f.start),
original: toYmdOr(where, 'original', f.original),
reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)),
delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined,
learning: f.learning != null ? String(f.learning) : undefined,
status: f.status as StatusLevel | undefined,
note: f.note != null ? String(f.note) : undefined,
}
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
function blockWhere(kind: string, block: unknown, i: number): string {
const name =
block != null && typeof block === "object" && "name" in block
? (block as { name: unknown }).name
: undefined
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
}
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
if (m.requires != null && !Array.isArray(m.requires)) {
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
}
return {
name: String(m.name),
week: toYmdOr(where, 'week', m.week),
requires: ((m.requires as unknown[]) ?? []).map(String),
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
function check<S extends v.GenericSchema>(
schema: S,
value: unknown,
where: string,
field?: string,
): v.InferOutput<S> {
const result = v.safeParse(schema, value)
if (!result.success) {
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
}
return result.output
}
function toYmdOr(where: string, field: string, value: unknown): string {
try {
return toYmd(value)
} catch {
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
type Issue = {
readonly message: string
readonly received: string
readonly path?: ReadonlyArray<{ readonly key: unknown }>
}
/** Render an issue against the offending field, e.g. "`start` must be a date"
* — or "missing `original`" when the key is absent (valibot reports its own
* "Invalid key" wording for that, which isn't fit to show the author). */
function friendly(issue: Issue, fallbackField?: string): string {
const key = issue.path?.[0]?.key
const field = typeof key === "string" ? key : fallbackField
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
return field ? `\`${field}\` ${issue.message}` : issue.message
}

View File

@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest'
import { parseMacroplan, PlanParseError } from './parse'
import { buildPlan } from './plan'
import { mondayOf, weekRange } from './week'
import type { FeatureRow } from './types'
import { SAMPLE_PLAN } from '../data/sample'
import { describe, it, expect } from "vitest"
import exampleToml from "../../docs/macroplan.example.toml?raw"
import { parseMacroplan, PlanParseError } from "./parse"
import { buildPlan } from "./plan"
import { mondayOf, weekRange } from "./week"
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 {
const plan = buildPlan(parseMacroplan(source), TODAY)
@@ -14,170 +15,209 @@ function rowOf(source: string, name: string): FeatureRow {
return row
}
describe('week math', () => {
it('snaps any day to its Monday', () => {
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-21')).toBe('2026-06-15') // Sun → that week's Mon
expect(mondayOf('2026-06-01')).toBe('2026-06-01') // Mon
describe("week math", () => {
it("snaps any day to its Monday", () => {
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-21")).toBe("2026-06-15") // Sun → that week's Mon
expect(mondayOf("2026-06-01")).toBe("2026-06-01") // Mon
})
it('builds an inclusive contiguous Monday range', () => {
expect(weekRange('2026-06-01', '2026-06-22')).toEqual([
'2026-06-01',
'2026-06-08',
'2026-06-15',
'2026-06-22',
it("builds an inclusive contiguous Monday range", () => {
expect(weekRange("2026-06-01", "2026-06-22")).toEqual([
"2026-06-01",
"2026-06-08",
"2026-06-15",
"2026-06-22",
])
})
})
describe('F2 — on-time / late classification (ADR-0001)', () => {
describe("F2 — on-time / late classification (ADR-0001)", () => {
const base = (extra: string) =>
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n`
it('delivered on the Original Estimate week → ◉, no ◯, onTime true', () => {
const r = rowOf(base('delivered = 2026-06-15'), 'X')
it("delivered on the Original Estimate week → ◉, no ◯, onTime true", () => {
const r = rowOf(base("delivered = 2026-06-15"), "X")
expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
expect(r.markers[0].week).toBe('2026-06-15')
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe("2026-06-15")
})
it('delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery', () => {
const r = rowOf(base('delivered = 2026-06-08'), 'X')
it("delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery", () => {
const r = rowOf(base("delivered = 2026-06-08"), "X")
expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
expect(r.markers[0].week).toBe('2026-06-08')
expect(r.barEndWeek).toBe('2026-06-08') // no ◯ dangling in the future
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe("2026-06-08")
expect(r.barEndWeek).toBe("2026-06-08") // no ◯ dangling in the future
})
it('delivered after the Original Estimate → ▲ late, ◯ baseline preserved', () => {
const r = rowOf(base('delivered = 2026-06-29'), 'X')
it("delivered after the Original Estimate → ▲ late, ◯ baseline preserved", () => {
const r = rowOf(base("delivered = 2026-06-29"), "X")
expect(r.onTime).toBe(false)
const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort()
expect(kinds).toEqual(['delivered-late@2026-06-29', 'original@2026-06-15'])
expect(r.barEndWeek).toBe('2026-06-29')
expect(kinds).toEqual(["delivered-late@2026-06-29", "original@2026-06-15"])
expect(r.barEndWeek).toBe("2026-06-29")
})
it('late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)', () => {
const r = rowOf(
base('reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20'),
'X',
)
it("late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)", () => {
const r = rowOf(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.slipCount).toBe(2)
const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => {
;(acc[m.kind] ??= []).push(m.week)
return acc
}, {})
expect(byKind.original).toEqual(['2026-06-15'])
expect(byKind.reestimate?.sort()).toEqual(['2026-06-29', '2026-07-13'])
expect(byKind['delivered-late']).toEqual(['2026-07-20'])
expect(byKind.original).toEqual(["2026-06-15"])
expect(byKind.reestimate?.sort()).toEqual(["2026-06-29", "2026-07-13"])
expect(byKind["delivered-late"]).toEqual(["2026-07-20"])
})
it('in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate', () => {
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "red"'), 'X')
it("in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate", () => {
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), "X")
expect(r.onTime).toBeNull()
expect(r.delivered).toBe(false)
expect(r.status).toBe('red')
expect(r.barEndWeek).toBe('2026-06-29') // furthest open estimate
expect(r.markers.some((m) => m.kind === 'original')).toBe(true)
expect(r.status).toBe("off-track")
expect(r.barEndWeek).toBe("2026-06-29") // furthest open estimate
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) =>
`[[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
const r = rowOf(feat('status = "red"'), 'X')
const r = rowOf(feat('status = "off-track"'), "X")
expect(r.delivered).toBe(false)
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.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")
})
it('does not extend a delivered bar past its delivery, even when now is later', () => {
const r = rowOf(feat('delivered = 2026-06-08'), 'X')
expect(r.barEndWeek).toBe('2026-06-08') // ends at delivery, not at the now week
it("does not extend a delivered bar past its delivery, even when now is later", () => {
const r = rowOf(feat("delivered = 2026-06-08"), "X")
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)', () => {
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', 'X')
expect(r.barEndWeek).toBe('2026-07-06')
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")
expect(r.barEndWeek).toBe("2026-07-06")
})
})
describe('plan derivation', () => {
it('derives a contiguous week range and places the now line', () => {
describe("plan derivation", () => {
it("derives a contiguous week range and places the now line", () => {
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.at(-1)).toBe('2026-08-03') // authored span end (trailing past last marker)
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)
// contiguous, weekly
expect(plan.weeks).toContain('2026-06-29')
expect(plan.nowWeek).toBe('2026-06-15')
expect(plan.weeks).toContain("2026-06-29")
expect(plan.nowWeek).toBe("2026-06-15")
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 mvp = plan.milestones.find((m) => m.name === 'MVP go-live')!
expect(mvp.week).toBe('2026-07-06')
const mvp = plan.milestones.find((m) => m.name === "MVP go-live")!
expect(mvp.week).toBe("2026-07-06")
// 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)', () => {
const body = '[[feature]]\nname="X"\nstart=2026-06-08\noriginal=2026-06-15\ndelivered=2026-06-15\n'
describe("authored plan span (start / end)", () => {
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)
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.at(-1)).toBe('2026-06-29') // after the last marker (06-15)
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.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)
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[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
})
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
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.at(-1)).toBe('2026-06-15')
expect(plan.weeks[0]).toBe("2026-06-08")
expect(plan.weeks.at(-1)).toBe("2026-06-15")
})
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)
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)
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', () => {
expect(() => parseMacroplan('start = 123\n')).toThrow(/start/)
it("rejects a non-date span bound", () => {
expect(() => parseMacroplan("start = 123\n")).toThrow(/start/)
})
})
describe('parse validation', () => {
it('rejects a feature missing its Original Estimate', () => {
describe("parse validation", () => {
it("rejects a feature missing its Original Estimate", () => {
expect(() => parseMacroplan('[[feature]]\nname = "A"\nstart = 2026-06-01\n')).toThrow(
PlanParseError,
)
})
it('rejects an invalid status', () => {
it("rejects an invalid status", () => {
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/)
})
it('parses the bundled sample without error', () => {
it("parses the bundled sample without error", () => {
const raw = parseMacroplan(SAMPLE_PLAN)
expect(raw.features).toHaveLength(5)
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 type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from './types'
import { mondayOf, weekRange, type WeekId } from "./week"
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from "./types"
/**
* Derive the render-ready Plan from the raw model (component C2).
@@ -17,7 +17,7 @@ export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan
const row = rows.find((r) => r.name === name)
if (!row) return true // references an unknown Feature → can't be met
const delivery = row.markers.find(
(x) => x.kind === 'delivered-on-time' || x.kind === 'delivered-late',
(x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
)
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 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) {
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
// occupies (or precedes) it — then the delivery marker speaks for it.
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))

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 ──
@@ -32,10 +32,10 @@ export interface RawPlan {
// ── Derived model: render-ready (C2 output) ──
export type MarkerKind =
| 'original' // ◯ Original Estimate, not yet delivered
| 'reestimate' // △ a slip to a later week
| 'delivered-on-time' // ◉ delivered on/before the Original Estimate
| 'delivered-late' // ▲ delivered after the Original Estimate
| "original" // ◯ Original Estimate, not yet delivered
| "reestimate" // △ a slip to a later week
| "delivered-on-time" // ◉ delivered on/before the Original Estimate
| "delivered-late" // ▲ delivered after the Original Estimate
export interface Marker {
week: WeekId

View File

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

View File

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

View File

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