Compare commits

...

35 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
42 changed files with 2234 additions and 748 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 # 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 ## Language
@@ -33,7 +33,7 @@ A free-text takeaway captured against a Feature once it is delivered — what th
_Avoid_: retro note, lesson, postmortem _Avoid_: retro note, lesson, postmortem
**Status**: **Status**:
A Feature's *current* delivery confidence (a snapshot, overwritten each review): **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. A Feature's _current_ delivery confidence (a snapshot, overwritten each review): **on-track** (all good), **at-risk** (in trouble but we have a plan), **off-track** (in trouble and we have no plan). May carry a comment. Applies only while in-flight; once delivered, the **Learning** takes over and the Status is dropped. An overdue Feature (past its latest estimate, not delivered) is expressed through an at-risk/off-track Status, not a dedicated symbol.
_Avoid_: health, RAG, risk _Avoid_: health, RAG, risk
**Week**: **Week**:
@@ -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. 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 _Avoid_: workspace, project, file list
**Format version**:
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
_Avoid_: schema version, file version, app version
## Symbols ## Symbols
- `┣` start of a Feature's bar - `┣` start of a Feature's bar

348
DESIGN.md
View File

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

View File

@@ -1,6 +1,6 @@
# Macroplan # Macroplan
A week-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. 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.
@@ -30,9 +30,10 @@ Dashboard ┣━━━━━◯ 🔴 n
## Documentation ## Documentation
| Document | What it covers | | Document | What it covers |
|----------|----------------| | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend | | [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). |
| [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 | | [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 |
| [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 | | [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/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth | | [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
| [docs/adr/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth |

154
docs/format.md Normal file
View File

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

View File

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

View File

@@ -29,12 +29,14 @@
Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename + download utility. Foundational and dependency-free. Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename + download utility. Foundational and dependency-free.
**Files:** **Files:**
- Modify: `src/composables/usePngExport.ts` (extract `slugify`, keep `exportFilename` output identical) - Modify: `src/composables/usePngExport.ts` (extract `slugify`, keep `exportFilename` output identical)
- Create: `src/composables/useSourceExport.ts` - Create: `src/composables/useSourceExport.ts`
- Test: `src/composables/useSourceExport.test.ts` - Test: `src/composables/useSourceExport.test.ts`
- (Unchanged, must still pass: `src/composables/usePngExport.test.ts`) - (Unchanged, must still pass: `src/composables/usePngExport.test.ts`)
**Interfaces:** **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: `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: `sourceFilename(title: string): string``macroplan-<slug-or-plan>.toml`
- Produces: `downloadSource(source: string, filename: string): void` - 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`: Create `src/composables/useSourceExport.test.ts`:
```ts ```ts
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { sourceFilename } from './useSourceExport' import { sourceFilename } from "./useSourceExport"
describe('sourceFilename', () => { describe("sourceFilename", () => {
it('slugifies the plan title into a .toml name', () => { it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.toml') 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', () => { 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")
expect(sourceFilename('—— ··')).toBe('macroplan-plan.toml') expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
}) })
}) })
``` ```
@@ -74,31 +76,31 @@ export function slugify(title: string): string {
return title return title
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, '') .replace(/^-+|-+$/g, "")
} }
/** Slugified, stable download name derived from the plan title. */ /** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string { export function exportFilename(title: string): string {
return `macroplan-${slugify(title) || 'plan'}.png` return `macroplan-${slugify(title) || "plan"}.png`
} }
``` ```
- [ ] **Step 4: Create `useSourceExport.ts`** - [ ] **Step 4: Create `useSourceExport.ts`**
```ts ```ts
import { slugify } from './usePngExport' import { slugify } from "./usePngExport"
/** Slugified, stable download name for a plan's TOML source. */ /** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string { 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). */ /** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void { 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 url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement("a")
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()
@@ -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. 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:** **Files:**
- Create: `src/components/PlanSwitcher.vue` - Create: `src/components/PlanSwitcher.vue`
- Test: `src/components/PlanSwitcher.test.ts` - Test: `src/components/PlanSwitcher.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: nothing from other tasks. - Consumes: nothing from other tasks.
- Produces: component `PlanSwitcher` with - Produces: component `PlanSwitcher` with
- Props: `plans: { id: string; name: string }[]`, `activeId: string` - Props: `plans: { id: string; name: string }[]`, `activeId: string`
@@ -141,36 +145,36 @@ Create `src/components/PlanSwitcher.test.ts`:
```ts ```ts
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { mount } from '@vue/test-utils' import { mount } from "@vue/test-utils"
import PlanSwitcher from './PlanSwitcher.vue' import PlanSwitcher from "./PlanSwitcher.vue"
const plans = [ const plans = [
{ id: 'a', name: 'Alpha' }, { id: "a", name: "Alpha" },
{ id: 'b', name: 'Bravo' }, { id: "b", name: "Bravo" },
] ]
describe('PlanSwitcher', () => { describe("PlanSwitcher", () => {
it('lists plan names and marks the active one', () => { it("lists plan names and marks the active one", () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'b' } }) const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
expect(w.text()).toContain('Alpha') expect(w.text()).toContain("Alpha")
expect(w.text()).toContain('Bravo') expect(w.text()).toContain("Bravo")
expect(w.find('a.active').text()).toContain('Bravo') expect(w.find("a.active").text()).toContain("Bravo")
}) })
it('emits select with the clicked plan id', async () => { it("emits select with the clicked plan id", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } }) const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
await w.findAll('li a')[1].trigger('click') // Bravo await w.findAll("li a")[1].trigger("click") // Bravo
expect(w.emitted('select')?.[0]).toEqual(['b']) expect(w.emitted("select")?.[0]).toEqual(["b"])
}) })
it('emits new and download (with the active id) from the trailing actions', async () => { it("emits new and download (with the active id) from the trailing actions", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } }) const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
const actions = w.findAll('li a') const actions = w.findAll("li a")
await actions[actions.length - 2].trigger('click') // New plan await actions[actions.length - 2].trigger("click") // New plan
await actions[actions.length - 1].trigger('click') // Download .toml await actions[actions.length - 1].trigger("click") // Download .toml
expect(w.emitted('new')).toBeTruthy() expect(w.emitted("new")).toBeTruthy()
expect(w.emitted('download')?.[0]).toEqual(['a']) expect(w.emitted("download")?.[0]).toEqual(["a"])
}) })
}) })
``` ```
@@ -184,7 +188,7 @@ Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from "vue"
const props = defineProps<{ const props = defineProps<{
plans: { id: string; name: string }[] plans: { id: string; name: string }[]
@@ -198,7 +202,7 @@ const emit = defineEmits<{
}>() }>()
const activeName = computed( const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled', () => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
) )
</script> </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. 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:** **Files:**
- Modify (rewrite): `src/composables/useMacroplan.ts` - Modify (rewrite): `src/composables/useMacroplan.ts`
- Test: `src/composables/useMacroplan.test.ts` - Test: `src/composables/useMacroplan.test.ts`
**Interfaces:** **Interfaces:**
- Consumes: `parseMacroplan` (throws `PlanParseError`; defaults `title` to `'Untitled Macroplan'`), `buildPlan`, `SAMPLE_PLAN` (its title is `"Q3 — Checkout revamp"`). - 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): - Produces (the composable's return shape, relied on by Task 4):
- `source: WritableComputedRef<string>` — reads/writes the active plan's source - `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` - `selectPlan(id: string): void`
- `newPlan(): void` - `newPlan(): void`
- `deletePlan(id: string): 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)** - [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
@@ -267,18 +273,18 @@ Create `src/composables/useMacroplan.test.ts`:
```ts ```ts
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from 'vue' import { nextTick } from "vue"
import { useMacroplan } from './useMacroplan' import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from '../data/sample' import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = 'macroplan:library' const LIB_KEY = "macroplan:library"
const LEGACY_KEY = 'macroplan:source' const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear()) beforeEach(() => localStorage.clear())
describe('useMacroplan — load & migration', () => { describe("useMacroplan — load & migration", () => {
it('seeds one sample plan when storage is empty, and persists it immediately', () => { it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id) 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 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) localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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() expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
}) })
it('falls back to a fresh sample when the library JSON is corrupt', () => { it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, '{ not valid json') localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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( localStorage.setItem(
LIB_KEY, LIB_KEY,
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
activeId: 'gone', activeId: "gone",
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }], plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}), }),
) )
const m = useMacroplan() const m = useMacroplan()
expect(m.activeId.value).toBe('x') expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe('Kept') expect(m.plans.value[0].name).toBe("Kept")
}) })
}) })
describe('useMacroplan — active plan binding', () => { describe("useMacroplan — active plan binding", () => {
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => { it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan() const m = useMacroplan()
m.source.value = 'title = "Renamed"\n' m.source.value = 'title = "Renamed"\n'
await nextTick() await nextTick()
expect(m.plans.value[0].name).toBe('Renamed') expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain('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 m = useMacroplan()
const goodPlan = m.plan.value const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick() await nextTick()
expect(m.error.value).toBeTruthy() expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained 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', () => { describe("useMacroplan — CRUD", () => {
it('newPlan appends a sample plan and activates it', () => { it("newPlan appends a sample plan and activates it", () => {
const m = useMacroplan() const m = useMacroplan()
const firstId = m.activeId.value const firstId = m.activeId.value
m.newPlan() m.newPlan()
@@ -347,7 +353,7 @@ describe('useMacroplan — CRUD', () => {
expect(m.source.value).toBe(SAMPLE_PLAN) 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() const m = useMacroplan()
m.newPlan() // 2 plans; second is active m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value const [first, second] = m.plans.value
@@ -356,14 +362,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id) 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() const m = useMacroplan()
m.deletePlan(m.activeId.value) m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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 m = useMacroplan()
const aId = m.activeId.value const aId = m.activeId.value
m.newPlan() m.newPlan()
@@ -391,14 +397,14 @@ Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not e
Replace the entire file with: Replace the entire file with:
```ts ```ts
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from "vue"
import { parseMacroplan } from '../model/parse' import { parseMacroplan } from "../model/parse"
import { buildPlan } from '../model/plan' import { buildPlan } from "../model/plan"
import type { Plan } from '../model/types' import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from '../data/sample' import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = 'macroplan:library' const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = 'macroplan:source' const LEGACY_KEY = "macroplan:source"
export interface StoredPlan { export interface StoredPlan {
id: string id: string
@@ -423,7 +429,7 @@ function titleOf(source: string): string | null {
} }
function newStoredPlan(source: string): StoredPlan { 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 { 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`. 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:** **Files:**
- Modify (rewrite): `src/App.vue` - Modify (rewrite): `src/App.vue`
- Modify: `src/composables/useMacroplan.ts` (delete the temporary `resetToSample`) - Modify: `src/composables/useMacroplan.ts` (delete the temporary `resetToSample`)
**Interfaces:** **Interfaces:**
- Consumes: `useMacroplan` (`source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan`), `PlanSwitcher`, `sourceFilename` + `downloadSource`, `exportFilename` + `usePngExport`. - Consumes: `useMacroplan` (`source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan`), `PlanSwitcher`, `sourceFilename` + `downloadSource`, `exportFilename` + `usePngExport`.
- [ ] **Step 1: Remove the temporary `resetToSample` from `useMacroplan.ts`** - [ ] **Step 1: Remove the temporary `resetToSample` from `useMacroplan.ts`**
@@ -601,13 +609,13 @@ Replace the entire file with:
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from "vue"
import { useMacroplan } from './composables/useMacroplan' import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from './composables/usePngExport' import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from './composables/useSourceExport' import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from './components/PlanEditor.vue' import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from './components/MacroplanGrid.vue' import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from './components/PlanSwitcher.vue' import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan() const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport() const { busy, toast, copyPng, downloadPng } = usePngExport()
@@ -616,7 +624,7 @@ const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false) const confirmingDelete = ref(false)
const activeName = computed( 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() { function downloadToml() {
@@ -659,7 +667,11 @@ function confirmDelete() {
> >
Download Download
</button> </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> </button>
</header> </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** - [ ] **Step 5: Manual smoke check in the dev server**
Run: `pnpm dev` and open the app, then verify each: 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). 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). 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. 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. Record the new `Library` term in the ubiquitous language and flip the README now that the library exists.
**Files:** **Files:**
- Modify: `CONTEXT.md` - Modify: `CONTEXT.md`
- Modify: `README.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: In the `## Language` section, immediately after the **Now line** entry (the block ending `_Avoid_: today marker, cursor`) and before `## Symbols`, insert:
```markdown ```markdown
**Library**: **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. 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 _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 ## Self-Review
**1. Spec coverage** **1. Spec coverage**
- Multiple named plans in localStorage → Task 3 (`Library` model). - Multiple named plans in localStorage → Task 3 (`Library` model).
- Header dropdown switcher → Task 2 (`PlanSwitcher`) + Task 4 (wiring). - Header dropdown switcher → Task 2 (`PlanSwitcher`) + Task 4 (wiring).
- New plan from sample → Task 3 (`newPlan`) + Task 4. - New plan from sample → Task 3 (`newPlan`) + Task 4.

View File

@@ -2,7 +2,7 @@
**Date:** 2026-06-17 **Date:** 2026-06-17
**Status:** Approved (brainstorming) → ready for implementation plan **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 ## Problem
@@ -13,6 +13,7 @@ This adds the library: keep several named Macroplans, switch between them, creat
## Scope ## Scope
**In:** **In:**
- Multiple named Macroplans persisted in localStorage. - Multiple named Macroplans persisted in localStorage.
- A header dropdown to switch the active plan. - A header dropdown to switch the active plan.
- Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library). - Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library).
@@ -21,8 +22,9 @@ This adds the library: keep several named Macroplans, switch between them, creat
- Migration from the legacy single-key store. - Migration from the legacy single-key store.
**Out (YAGNI for v1):** **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. - Reordering, folders, search, tags.
- Any backend or multi-device sync (explicitly rejected by ADR-0002). - Any backend or multi-device sync (explicitly rejected by ADR-0002).
@@ -46,11 +48,11 @@ macroplan:library → {
} }
``` ```
| Field | Meaning | | 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. | | `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. | | `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. | | `source` | The TOML string the editor binds to. |
**Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3. **Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3.
@@ -76,16 +78,16 @@ Holds `plans` and `activeId`; persists the whole library to `macroplan:library`
Exposes: Exposes:
| Member | Type | Notes | | 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. | | `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). | | `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. | | `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. | | `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
| `activeId` | `Ref<string>` | Current selection. | | `activeId` | `Ref<string>` | Current selection. |
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. | | `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). | | `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. | | `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
`resetToSample` is **removed**`newPlan()` (a blank page) replaces it. `resetToSample` is **removed**`newPlan()` (a blank page) replaces it.
@@ -133,6 +135,7 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
## Testing (Vitest, matching the existing suite) ## Testing (Vitest, matching the existing suite)
**`useMacroplan` / library model:** **`useMacroplan` / library model:**
- No storage → library seeds one sample plan, active. - No storage → library seeds one sample plan, active.
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed. - Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
- Corrupt `macroplan:library` → seeds fresh sample (no throw). - Corrupt `macroplan:library` → seeds fresh sample (no throw).
@@ -143,15 +146,17 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
- `selectPlan` swaps `source`/`plan`/`error` and resets `lastGood` (broken target shows its own error, not the prior render). - `selectPlan` swaps `source`/`plan`/`error` and resets `lastGood` (broken target shows its own error, not the prior render).
**Filename util:** **Filename util:**
- `sourceFilename(title)` slug mirrors the existing `exportFilename` test (e.g. `"Q3 Delivery"``macroplan-q3-delivery.toml`); empty/garbage title → `macroplan-plan.toml`. - `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`):** **Component (`@vue/test-utils`):**
- `PlanSwitcher` renders the plan names, marks the active one, and emits `select` / `new` / `delete` / `download` on the right interactions. - `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) ## Docs to update (in the implementation plan)
- **`CONTEXT.md`** — add the **Library** glossary entry above. - **`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 ## Open questions

View File

@@ -1,13 +1,17 @@
{ {
"name": "macroplan", "name": "macroplan",
"private": true,
"version": "0.0.0", "version": "0.0.0",
"private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run" "test": "vitest run",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
}, },
"dependencies": { "dependencies": {
"daisyui": "^5.5.23", "daisyui": "^5.5.23",
@@ -24,6 +28,8 @@
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.10.4", "happy-dom": "^20.10.4",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",

442
pnpm-lock.yaml generated
View File

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

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 // @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from "vitest"
import { mount } from '@vue/test-utils' import { mount } from "@vue/test-utils"
import { nextTick } from 'vue' import { nextTick } from "vue"
// downloadSource performs a real DOM/Blob download; mock it so we can assert // downloadSource performs a real DOM/Blob download; mock it so we can assert
// the wiring without touching the filesystem. sourceFilename stays real. // the wiring without touching the filesystem. sourceFilename stays real.
vi.mock('./composables/useSourceExport', async (importOriginal) => { vi.mock("./composables/useSourceExport", async (importOriginal) => {
const actual = await importOriginal<typeof import('./composables/useSourceExport')>() const actual = await importOriginal<typeof import("./composables/useSourceExport")>()
return { ...actual, downloadSource: vi.fn() } return { ...actual, downloadSource: vi.fn() }
}) })
import App from './App.vue' import App from "./App.vue"
import { downloadSource } from './composables/useSourceExport' import { downloadSource } from "./composables/useSourceExport"
import { SAMPLE_PLAN } from './data/sample' import { SAMPLE_PLAN } from "./data/sample"
// The editor lazy-loads Shiki and the grid is heavy; neither is under test here. // The editor lazy-loads Shiki and the grid is heavy; neither is under test here.
const stubs = { PlanEditor: true, MacroplanGrid: true } const stubs = { PlanEditor: true, MacroplanGrid: true }
@@ -23,69 +23,67 @@ function mountApp() {
// dropdown links = one <a> per plan, then "New plan" and "Download .toml". // dropdown links = one <a> per plan, then "New plan" and "Download .toml".
function dropdownLinks(w: ReturnType<typeof mountApp>) { function dropdownLinks(w: ReturnType<typeof mountApp>) {
return w.findAll('.dropdown-content li a') return w.findAll(".dropdown-content li a")
} }
beforeEach(() => localStorage.clear()) beforeEach(() => localStorage.clear())
describe('App — plan library wiring', () => { describe("App — plan library wiring", () => {
it('shows the active plan name in the header switcher', () => { it("shows the active plan name in the header switcher", () => {
expect(mountApp().text()).toContain('Q3 — Checkout revamp') 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() const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan + New + Download 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() await nextTick()
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download 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() const w = mountApp()
await dropdownLinks(w)[1].trigger('click') // New → 2 plans await dropdownLinks(w)[1].trigger("click") // New → 2 plans
await nextTick() await nextTick()
expect(dropdownLinks(w)).toHaveLength(4) expect(dropdownLinks(w)).toHaveLength(4)
await w.find('button[title="Delete this plan"]').trigger('click') await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find('.modal-open').exists()).toBe(true) 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() 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 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() const w = mountApp()
await w.find('button[title="Delete this plan"]').trigger('click') await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find('.modal-open').exists()).toBe(true) expect(w.find(".modal-open").exists()).toBe(true)
const cancel = w const cancel = w.findAll(".modal-action .btn").find((b) => !b.classes().includes("btn-error"))!
.findAll('.modal-action .btn') await cancel.trigger("click")
.find((b) => !b.classes().includes('btn-error'))! expect(w.find(".modal-open").exists()).toBe(false)
await cancel.trigger('click')
expect(w.find('.modal-open').exists()).toBe(false)
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched 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() const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan expect(dropdownLinks(w)).toHaveLength(3) // 1 plan
await w.find('button[title="Delete this plan"]').trigger('click') 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(".modal-action .btn-error").trigger("click") // Delete the only plan
await nextTick() await nextTick()
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded 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() 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() expect(downloadSource).toHaveBeenCalledOnce()
const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0] const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0]
expect(src).toBe(SAMPLE_PLAN) 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"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from "vue"
import { useMacroplan } from './composables/useMacroplan' import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from './composables/usePngExport' import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from './composables/useSourceExport' import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from './components/PlanEditor.vue' import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from './components/MacroplanGrid.vue' import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from './components/PlanSwitcher.vue' import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan() const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport() const { busy, toast, copyPng, downloadPng } = usePngExport()
@@ -13,8 +13,30 @@ const { busy, toast, copyPng, downloadPng } = usePngExport()
const exportRoot = ref<HTMLElement>() const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false) 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( 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() { function downloadToml() {
@@ -40,9 +62,38 @@ function confirmDelete() {
@download="downloadToml" @download="downloadToml"
/> />
</div> </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 <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
:disabled="!plan || busy" :disabled="!plan || busy || !showMacroplan"
title="Copy the rendered plan to the clipboard as a PNG" title="Copy the rendered plan to the clipboard as a PNG"
@click="copyPng(exportRoot)" @click="copyPng(exportRoot)"
> >
@@ -51,7 +102,7 @@ function confirmDelete() {
</button> </button>
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
:disabled="!plan || busy" :disabled="!plan || busy || !showMacroplan"
title="Download the rendered plan as a PNG" title="Download the rendered plan as a PNG"
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))" @click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
> >
@@ -69,7 +120,11 @@ function confirmDelete() {
<main class="flex min-h-0 flex-1 flex-col md:flex-row"> <main class="flex min-h-0 flex-1 flex-col md:flex-row">
<section <section
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r" v-if="showSource"
class="flex min-h-0 flex-col border-base-300"
:class="
showMacroplan ? 'max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r' : 'flex-1'
"
> >
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50"> <div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Source (TOML) Source (TOML)
@@ -77,7 +132,7 @@ function confirmDelete() {
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" /> <PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
</section> </section>
<section class="min-h-0 flex-1 overflow-auto p-4"> <section v-if="showMacroplan" class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="plan" ref="exportRoot" class="export-root"> <div v-if="plan" ref="exportRoot" class="export-root">
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2> <h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
<MacroplanGrid :plan="plan" /> <MacroplanGrid :plan="plan" />
@@ -124,4 +179,12 @@ function confirmDelete() {
.export-root.exporting :deep(.macroplan) { .export-root.exporting :deep(.macroplan) {
overflow: visible; overflow: visible;
} }
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
status notes) must wrap to its full length in the capture instead of being cut.
The column width is unchanged, so the milestone band stays aligned. */
.export-root.exporting :deep(.namecell .truncate),
.export-root.exporting :deep(.learncell .note) {
white-space: normal;
overflow: visible;
}
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from 'shiki/core' import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from './completion' import { getCompletions, type CompletionContext } from "./completion"
import { fitPopup } from "./popupFit"
const props = defineProps<{ modelValue: string; error: string | null }>() const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>() const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>() const backdrop = ref<HTMLElement>()
const popupEl = ref<HTMLElement>()
const highlighter = ref<HighlighterCore>() const highlighter = ref<HighlighterCore>()
// ── Completion popup state ─────────────────────────────────────────────────── // ── Completion popup state ───────────────────────────────────────────────────
@@ -25,10 +27,10 @@ onMounted(async () => {
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle. // Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM). // Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
const [core, engine, toml, theme] = await Promise.all([ const [core, engine, toml, theme] = await Promise.all([
import('shiki/core'), import("shiki/core"),
import('shiki/engine/javascript'), import("shiki/engine/javascript"),
import('shiki/langs/toml.mjs'), import("shiki/langs/toml.mjs"),
import('shiki/themes/github-light.mjs'), import("shiki/themes/github-light.mjs"),
]) ])
highlighter.value = await core.createHighlighterCore({ highlighter.value = await core.createHighlighterCore({
themes: [theme.default], themes: [theme.default],
@@ -41,11 +43,11 @@ function measure() {
const el = textarea.value const el = textarea.value
if (!el) return if (!el) return
const cs = getComputedStyle(el) const cs = getComputedStyle(el)
const ctx = document.createElement('canvas').getContext('2d')! const ctx = document.createElement("canvas").getContext("2d")!
ctx.font = `${cs.fontSize} ${cs.fontFamily}` ctx.font = `${cs.fontSize} ${cs.fontFamily}`
const lh = parseFloat(cs.lineHeight) // px value in every modern browser const lh = parseFloat(cs.lineHeight) // px value in every modern browser
metrics = { metrics = {
charWidth: ctx.measureText('0000000000').width / 10, charWidth: ctx.measureText("0000000000").width / 10,
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh, lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
padLeft: parseFloat(cs.paddingLeft), padLeft: parseFloat(cs.paddingLeft),
padTop: parseFloat(cs.paddingTop), padTop: parseFloat(cs.paddingTop),
@@ -66,14 +68,33 @@ function position() {
const el = textarea.value const el = textarea.value
if (!el) return if (!el) return
const before = el.value.slice(0, el.selectionStart) const before = el.value.slice(0, el.selectionStart)
const lineIndex = before.split('\n').length - 1 const lineIndex = before.split("\n").length - 1
const line = before.slice(before.lastIndexOf('\n') + 1) const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0 let col = 0
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1 for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
popup.value = { const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft, const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
top: metrics.padTop + (lineIndex + 1) * 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) { function accept(i: number) {
@@ -83,42 +104,53 @@ function accept(i: number) {
const item = ctx.items[i] const item = ctx.items[i]
const caret = ctx.from + item.insert.length const caret = ctx.from + item.insert.length
completion.value = null completion.value = null
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to)) emit("update:modelValue", el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
nextTick(() => { nextTick(() => {
el.setSelectionRange(caret, caret) el.setSelectionRange(caret, caret)
el.focus() el.focus()
refresh() // chain, e.g. `status = ` immediately offers the enum values // 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) { function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') justEscaped = false if (e.key !== "Escape") justEscaped = false
const ctx = completion.value const ctx = completion.value
if (!ctx) return 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) { switch (e.key) {
case 'ArrowDown': case "ArrowDown":
e.preventDefault() move(1)
selected.value = (selected.value + 1) % ctx.items.length
break break
case 'ArrowUp': case "ArrowUp":
e.preventDefault() move(-1)
selected.value = (selected.value - 1 + ctx.items.length) % ctx.items.length
break break
case 'Enter': case "Enter":
case 'Tab': 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() e.preventDefault()
accept(selected.value) accept(selected.value)
break break
case 'Escape': case "Escape":
e.preventDefault() e.preventDefault()
completion.value = null completion.value = null
justEscaped = true justEscaped = true
break break
// Caret jumps would leave the popup stranded at a stale spot — close it. // Caret jumps would leave the popup stranded at a stale spot — close it.
case 'ArrowLeft': case "ArrowLeft":
case 'ArrowRight': case "ArrowRight":
case 'Home': case "Home":
case 'End': case "End":
completion.value = null completion.value = null
break break
} }
@@ -126,18 +158,24 @@ function onKeydown(e: KeyboardEvent) {
const highlighted = computed(() => { const highlighted = computed(() => {
// a trailing newline needs a trailing char so the last backdrop line keeps height // a trailing newline needs a trailing char so the last backdrop line keeps height
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue const code = props.modelValue.endsWith("\n") ? props.modelValue + " " : props.modelValue
const hl = highlighter.value const hl = highlighter.value
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>` if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
return hl.codeToHtml(code, { lang: 'toml', theme: 'github-light' }) return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
}) })
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!) return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)
} }
function onInput(e: Event) { function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value) emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
// Deleting text (backspace, forward-delete, cut) shouldn't summon the popup —
// the author is removing, not asking for suggestions. Close it and stop.
if ((e as InputEvent).inputType?.startsWith("delete")) {
completion.value = null
return
}
refresh() refresh()
} }
@@ -169,6 +207,7 @@ function syncScroll() {
></textarea> ></textarea>
<ul <ul
v-if="completion" v-if="completion"
ref="popupEl"
class="completion" class="completion"
:style="{ left: popup.left + 'px', top: popup.top + 'px' }" :style="{ left: popup.left + 'px', top: popup.top + 'px' }"
> >
@@ -213,7 +252,11 @@ function syncScroll() {
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
font-family: inherit; /* Fira Code, from the global theme */ font-family: inherit; /* Fira Code, from the global theme */
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.6; /* Integer px, NOT a unitless ratio: a fractional line box (0.8rem × 1.6 =
20.48px) is rounded differently by a <textarea> than by the <pre> backdrop,
so the caret drifts further from its glyph the more lines precede it —
visible at the bottom of a tall file. An integer height rounds identically. */
line-height: 20px;
tab-size: 2; tab-size: 2;
white-space: pre; white-space: pre;
} }
@@ -258,8 +301,8 @@ function syncScroll() {
margin: 0; margin: 0;
padding: 0.25rem; padding: 0.25rem;
list-style: none; list-style: none;
min-width: 13rem; min-width: 16rem;
max-width: 22rem; max-width: 30rem;
max-height: 14rem; max-height: 14rem;
overflow-y: auto; overflow-y: auto;
background: var(--color-base-100); background: var(--color-base-100);

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { getCompletions } from './completion' import { getCompletions } from "./completion"
/** Split a `|`-marked string into (source, caret offset). */ /** Split a `|`-marked string into (source, caret offset). */
function atCaret(marked: string): [string, number] { function atCaret(marked: string): [string, number] {
const caret = marked.indexOf('|') const caret = marked.indexOf("|")
return [marked.slice(0, caret) + marked.slice(caret + 1), caret] return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
} }
@@ -13,85 +13,169 @@ function labelsAt(marked: string): string[] {
return getCompletions(source, caret)?.items.map((i) => i.label) ?? [] return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
} }
describe('completion context', () => { describe("completion context", () => {
it('offers plan keys and block headers on an empty top-level line', () => { it("offers plan keys on an empty top-level line", () => {
expect(labelsAt('|')).toEqual(['title', 'start', 'end', '[[feature]]', '[[milestone]]']) // 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', () => { it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
expect(labelsAt('[[feature]]\n|')).toEqual([ expect(labelsAt("[[feature]]\n|")).toEqual([
'name', "name",
'start', "start",
'original', "original",
'reestimates', "reestimates",
'delivered', "delivered",
'status', "status",
'learning', "learning",
'note', "note",
'[[feature]]',
'[[milestone]]',
]) ])
}) })
it('offers milestone keys inside a [[milestone]] block', () => { it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
expect(labelsAt('[[milestone]]\n|')).toEqual([ expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
'name', })
'week',
'requires', it("keeps offering remaining keys on a single new line, still without headers", () => {
'[[feature]]', expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
'[[milestone]]', })
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', () => { it("excludes keys already written in the current block", () => {
const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|') const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|')
expect(labels).not.toContain('name') expect(labels).not.toContain("name")
expect(labels).not.toContain('original') expect(labels).not.toContain("original")
expect(labels).toContain('start') expect(labels).toContain("start")
}) })
it('filters keys by the typed prefix', () => { it("filters keys by the typed prefix", () => {
expect(labelsAt('[[feature]]\nst|')).toEqual(['start', 'status']) expect(labelsAt("[[feature]]\nst|")).toEqual(["start", "status"])
}) })
it('completes a header from a leading bracket', () => { it("completes a header from a leading bracket", () => {
expect(labelsAt('[[f|')).toEqual(['[[feature]]']) expect(labelsAt("[[f|")).toEqual(["[[feature]]"])
}) })
it('suggests status values after status =', () => { it("suggests status values after status =", () => {
expect(labelsAt('[[feature]]\nstatus = |')).toEqual(['on-track', 'at-risk', 'off-track']) expect(labelsAt("[[feature]]\nstatus = |")).toEqual(["on-track", "at-risk", "off-track"])
}) })
it('filters status values by the typed prefix, inside an open quote', () => { it("filters status values by the typed prefix, inside an open quote", () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(['on-track', 'off-track']) expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(["on-track", "off-track"])
}) })
it('suggests feature names inside a milestone requires array', () => { 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 = ["|' const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
expect(labelsAt(source)).toEqual(['Payments']) expect(labelsAt(source)).toEqual(["Payments"])
}) })
it('does not offer feature names outside a milestone block', () => { it("does not offer feature names outside a milestone block", () => {
// `requires` is meaningless at the plan level, so there is nothing to add. // `requires` is meaningless at the plan level, so there is nothing to add.
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([]) expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
}) })
it('returns nothing in a value position it cannot complete (a date)', () => { it("returns nothing in a value position it cannot complete (a date)", () => {
expect(getCompletions(...atCaret('[[feature]]\nstart = 2026-|'))).toBeNull() expect(getCompletions(...atCaret("[[feature]]\nstart = 2026-|"))).toBeNull()
}) })
}) })
describe('completion replace range', () => { describe("date completion", () => {
it('replaces the typed prefix, not the whole line', () => { const TODAY = "2026-06-19"
const source = '[[feature]]\nst' const datesAt = (marked: string) => {
const ctx = getCompletions(source, source.length)! const [source, caret] = atCaret(marked)
expect(source.slice(ctx.from, ctx.to)).toBe('st') 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('replaces a partial bracketed header from the bracket', () => { it("offers the same for plan dates and milestone weeks", () => {
const source = '[[f' expect(datesAt("end = |")!.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
const ctx = getCompletions(source, source.length)! expect(datesAt("[[milestone]]\nweek = |")!.items.map((i) => i.insert)).toEqual([
expect(source.slice(ctx.from, ctx.to)).toBe('[[f') "2026-06-19",
expect(ctx.items[0].insert).toBe('[[feature]]') "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

@@ -21,45 +21,50 @@ export interface CompletionContext {
items: Completion[] items: Completion[]
} }
type Block = 'plan' | 'feature' | 'milestone' type Block = "plan" | "feature" | "milestone"
const PLAN_KEYS: Completion[] = [ const PLAN_KEYS: Completion[] = [
{ label: 'title', insert: 'title = ', detail: 'plan title' }, { label: "title", insert: "title = ", detail: "plan title" },
{ label: 'start', insert: 'start = ', detail: 'left edge — date, optional' }, { label: "start", insert: "start = ", detail: "left edge — date, optional" },
{ label: 'end', insert: 'end = ', detail: 'right edge — date, optional' }, { label: "end", insert: "end = ", detail: "right edge — date, optional" },
] ]
const FEATURE_KEYS: Completion[] = [ const FEATURE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' }, { label: "name", insert: "name = ", detail: "required" },
{ label: 'start', insert: 'start = ', detail: 'date, required' }, { label: "start", insert: "start = ", detail: "date, required" },
{ label: 'original', insert: 'original = ', detail: 'Original Estimate — date, required' }, { label: "original", insert: "original = ", detail: "Original Estimate — date, required" },
{ label: 'reestimates', insert: 'reestimates = ', detail: 'list of dates' }, { label: "reestimates", insert: "reestimates = ", detail: "list of dates" },
{ label: 'delivered', insert: 'delivered = ', detail: 'date' }, { label: "delivered", insert: "delivered = ", detail: "date" },
{ label: 'status', insert: 'status = ', detail: 'on-track | at-risk | off-track' }, { label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" },
{ label: 'learning', insert: 'learning = ', detail: 'string' }, { label: "learning", insert: "learning = ", detail: "string" },
{ label: 'note', insert: 'note = ', detail: 'string' }, { label: "note", insert: "note = ", detail: "string" },
] ]
const MILESTONE_KEYS: Completion[] = [ const MILESTONE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' }, { label: "name", insert: "name = ", detail: "required" },
{ label: 'week', insert: 'week = ', detail: 'date, required' }, { label: "week", insert: "week = ", detail: "date, required" },
{ label: 'requires', insert: 'requires = ', detail: 'list of feature names' }, { label: "requires", insert: "requires = ", detail: "list of feature names" },
] ]
const HEADERS: Completion[] = [ const HEADERS: Completion[] = [
{ label: '[[feature]]', insert: '[[feature]]', detail: 'new feature', filter: 'feature' }, { label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" },
{ label: '[[milestone]]', insert: '[[milestone]]', detail: 'new milestone', filter: 'milestone' }, { label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" },
] ]
const STATUSES = ['on-track', 'at-risk', 'off-track'] // keep in sync with parse.ts 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. */ /** What to suggest at `caret` in `source`, or null when nothing applies.
export function getCompletions(source: string, caret: number): CompletionContext | null { * `today` (yyyy-mm-dd) seeds the date suggestions; injectable for tests. */
const lineStart = source.lastIndexOf('\n', caret - 1) + 1 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) const linePrefix = source.slice(lineStart, caret)
// ── value: status = "<here>" ────────────────────────────────────────────── // ── value: status = "<here>" — while typing, not once the quote is closed ──
const status = /^(\s*status\s*=\s*)"?([A-Za-z-]*)"?$/.exec(linePrefix) const status = /^(\s*status\s*=\s*)"?([A-Za-z-]*)$/.exec(linePrefix)
if (status) { if (status) {
const items = filter( const items = filter(
STATUSES.map((s) => ({ label: s, insert: `"${s}"` })), STATUSES.map((s) => ({ label: s, insert: `"${s}"` })),
@@ -68,10 +73,23 @@ export function getCompletions(source: string, caret: number): CompletionContext
return result(lineStart + status[1].length, caret, items) 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) const block = currentBlock(source, lineStart)
// ── value: requires = [ "<here>" … ] (milestones only) ─────────────────── // ── value: requires = [ "<here>" … ] (milestones only) ───────────────────
if (block === 'milestone' && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) { if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
const token = /("?)([^",[\]]*)$/.exec(linePrefix)! const token = /("?)([^",[\]]*)$/.exec(linePrefix)!
const items = filter( const items = filter(
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })), featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
@@ -85,12 +103,21 @@ export function getCompletions(source: string, caret: number): CompletionContext
if (key) { if (key) {
const [, indent, brackets, word] = key const [, indent, brackets, word] = key
if (brackets) { if (brackets) {
return result(lineStart + indent.length, caret, filter(HEADERS, word)) return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
} }
const keys = const keys =
block === 'feature' ? FEATURE_KEYS : block === 'milestone' ? MILESTONE_KEYS : PLAN_KEYS block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
const taken = presentKeys(source, lineStart, block) const taken = presentKeys(source, lineStart, block)
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word) 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 result(caret - word.length, caret, items)
} }
@@ -101,6 +128,40 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
return items.length ? { from, to, items } : 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[] { function filter(items: Completion[], word: string): Completion[] {
if (!word) return items if (!word) return items
const w = word.toLowerCase() const w = word.toLowerCase()
@@ -111,7 +172,7 @@ const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm
/** The block owning the line at `lineStart` — 'plan' before the first header. */ /** The block owning the line at `lineStart` — 'plan' before the first header. */
function currentBlock(source: string, lineStart: number): Block { function currentBlock(source: string, lineStart: number): Block {
let block: Block = 'plan' let block: Block = "plan"
HEADER_RE.lastIndex = 0 HEADER_RE.lastIndex = 0
let m: RegExpExecArray | null let m: RegExpExecArray | null
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) { while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
@@ -129,7 +190,7 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
let start = 0 let start = 0
let end = source.length let end = source.length
if (block === 'plan') { if (block === "plan") {
end = headers[0] ?? source.length end = headers[0] ?? source.length
} else { } else {
for (let i = 0; i < headers.length; i++) { for (let i = 0; i < headers.length; i++) {
@@ -151,11 +212,11 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
function featureNames(source: string): string[] { function featureNames(source: string): string[] {
const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm
const names = new Set<string>() const names = new Set<string>()
let block: Block = 'plan' let block: Block = "plan"
let m: RegExpExecArray | null let m: RegExpExecArray | null
while ((m = re.exec(source)) !== null) { while ((m = re.exec(source)) !== null) {
if (m[1]) block = m[1] as Block if (m[1]) block = m[1] as Block
else if (m[2] && block === 'feature') names.add(m[2]) else if (m[2] && block === "feature") names.add(m[2])
} }
return [...names] 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 // @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from 'vue' import { nextTick } from "vue"
import { useMacroplan } from './useMacroplan' import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from '../data/sample' import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = 'macroplan:library' const LIB_KEY = "macroplan:library"
const LEGACY_KEY = 'macroplan:source' const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear()) beforeEach(() => localStorage.clear())
describe('useMacroplan — load & migration', () => { describe("useMacroplan — load & migration", () => {
it('seeds one sample plan when storage is empty, and persists it immediately', () => { it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id) expect(m.activeId.value).toBe(m.plans.value[0].id)
@@ -18,80 +18,80 @@ describe('useMacroplan — load & migration', () => {
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload 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) localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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() expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
}) })
it('falls back to a fresh sample when the library JSON is corrupt', () => { it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, '{ not valid json') localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) expect(m.source.value).toBe(SAMPLE_PLAN)
}) })
it('falls back to a fresh sample when the stored library shape is malformed', () => { 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 // Valid JSON, wrong shape: a plan missing its `source` — would have slipped
// through the old blind `as Library` cast. // through the old blind `as Library` cast.
localStorage.setItem( localStorage.setItem(
LIB_KEY, LIB_KEY,
JSON.stringify({ version: 1, activeId: 'x', plans: [{ id: 'x', name: 'Bad' }] }), JSON.stringify({ version: 1, activeId: "x", plans: [{ id: "x", name: "Bad" }] }),
) )
const m = useMacroplan() const m = useMacroplan()
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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( localStorage.setItem(
LIB_KEY, LIB_KEY,
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
activeId: 'gone', activeId: "gone",
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }], plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}), }),
) )
const m = useMacroplan() const m = useMacroplan()
expect(m.activeId.value).toBe('x') expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe('Kept') expect(m.plans.value[0].name).toBe("Kept")
}) })
}) })
describe('useMacroplan — active plan binding', () => { describe("useMacroplan — active plan binding", () => {
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => { it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan() const m = useMacroplan()
m.source.value = 'title = "Renamed"\n' m.source.value = 'title = "Renamed"\n'
await nextTick() await nextTick()
expect(m.plans.value[0].name).toBe('Renamed') expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain('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 m = useMacroplan()
const goodPlan = m.plan.value const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick() await nextTick()
expect(m.error.value).toBeTruthy() expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained 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', () => { describe("useMacroplan — CRUD", () => {
it('newPlan appends a blank plan and activates it (the sample only seeds an empty library)', () => { it("newPlan appends a blank plan and activates it (the sample only seeds an empty library)", () => {
const m = useMacroplan() const m = useMacroplan()
const firstId = m.activeId.value const firstId = m.activeId.value
m.newPlan() m.newPlan()
expect(m.plans.value).toHaveLength(2) expect(m.plans.value).toHaveLength(2)
expect(m.activeId.value).not.toBe(firstId) expect(m.activeId.value).not.toBe(firstId)
expect(m.source.value).toBe('') // blank page, not a copy of the sample 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() const m = useMacroplan()
m.newPlan() // 2 plans; second is active m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value const [first, second] = m.plans.value
@@ -100,14 +100,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id) 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() const m = useMacroplan()
m.deletePlan(m.activeId.value) m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1) expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN) 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 m = useMacroplan()
const aId = m.activeId.value const aId = m.activeId.value
m.newPlan() m.newPlan()

View File

@@ -1,12 +1,12 @@
import { ref, shallowRef, computed, watch } from 'vue' import { ref, shallowRef, computed, watch } from "vue"
import * as v from 'valibot' import * as v from "valibot"
import { parseMacroplan } from '../model/parse' import { parseMacroplan } from "../model/parse"
import { buildPlan } from '../model/plan' import { buildPlan } from "../model/plan"
import type { Plan } from '../model/types' import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from '../data/sample' import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = 'macroplan:library' const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = 'macroplan:source' const LEGACY_KEY = "macroplan:source"
const StoredPlanSchema = v.object({ const StoredPlanSchema = v.object({
id: v.string(), id: v.string(),
@@ -33,7 +33,7 @@ function titleOf(source: string): string | null {
} }
function newStoredPlan(source: string): StoredPlan { 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 { function save(lib: Library): void {
@@ -145,7 +145,7 @@ export function useMacroplan() {
// empty library — first run (loadLibrary) or deleting the last plan // empty library — first run (loadLibrary) or deleting the last plan
// (deletePlan) — and the ≥1-plan invariant keeps the library non-empty // (deletePlan) — and the ≥1-plan invariant keeps the library non-empty
// here, so "+ New" never re-clones the sample over an existing library. // here, so "+ New" never re-clones the sample over an existing library.
const p = newStoredPlan('') const p = newStoredPlan("")
lib.value.plans.push(p) lib.value.plans.push(p)
switchTo(p.id) switchTo(p.id)
}, },

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from "vitest"
import { sourceFilename } from './useSourceExport' import { sourceFilename } from "./useSourceExport"
describe('sourceFilename', () => { describe("sourceFilename", () => {
it('slugifies the plan title into a .toml name', () => { it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.toml') 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', () => { 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")
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. */ /** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string { 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). */ /** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void { 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 url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement("a")
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()

View File

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

View File

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

View File

@@ -1,35 +1,35 @@
import * as v from 'valibot' import * as v from "valibot"
import { parse as parseToml } from 'smol-toml' import { parse as parseToml } from "smol-toml"
import { toYmd } from './week' import { toYmd } from "./week"
import type { RawPlan, StatusLevel } from './types' import type { RawPlan, StatusLevel } from "./types"
/** Thrown for any malformed source — message is safe to show the author. */ /** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error { export class PlanParseError extends Error {
constructor(message: string) { constructor(message: string) {
super(message) super(message)
this.name = 'PlanParseError' this.name = "PlanParseError"
} }
} }
const STATUSES = ['on-track', 'at-risk', 'off-track'] as const satisfies readonly StatusLevel[] const STATUSES = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[]
// ── Field schemas ────────────────────────────────────────────────────────── // ── Field schemas ──────────────────────────────────────────────────────────
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string, // A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
// normalized to yyyy-mm-dd via `toYmd`. // normalized to yyyy-mm-dd via `toYmd`.
const Ymd = v.pipe( const Ymd = v.pipe(
v.union([v.date(), v.string()], 'must be a date (e.g. 2026-06-01)'), v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
v.transform((value: string | Date) => toYmd(value)), v.transform((value: string | Date) => toYmd(value)),
) )
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(', ')}`) const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
const Name = v.pipe(v.string('is required'), v.nonEmpty('is required')) const Name = v.pipe(v.string("is required"), v.nonEmpty("is required"))
const FeatureSchema = v.object({ const FeatureSchema = v.object({
name: Name, name: Name,
start: Ymd, start: Ymd,
original: Ymd, original: Ymd,
reestimates: v.optional(v.array(Ymd, 'must be a list of dates'), []), reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []),
delivered: v.optional(Ymd), delivered: v.optional(Ymd),
learning: v.optional(v.string()), learning: v.optional(v.string()),
status: v.optional(Status), status: v.optional(Status),
@@ -39,9 +39,17 @@ const FeatureSchema = v.object({
const MilestoneSchema = v.object({ const MilestoneSchema = v.object({
name: Name, name: Name,
week: Ymd, week: Ymd,
requires: v.optional(v.array(v.string('must be a feature name'), 'must be a list of feature names'), []), requires: v.optional(
v.array(v.string("must be a feature name"), "must be a list of feature names"),
[],
),
}) })
/** The Macroplan format version this build understands (see docs/format.md).
* Files may declare `macroplan_version`; a newer version is rejected rather
* than silently mis-rendered against a schema we don't know. */
export const FORMAT_VERSION = 1
/** Parse + validate a Macroplan TOML source into the raw model. */ /** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan { export function parseMacroplan(source: string): RawPlan {
let data: Record<string, unknown> let data: Record<string, unknown>
@@ -51,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan {
throw new PlanParseError(e instanceof Error ? e.message : String(e)) throw new PlanParseError(e instanceof Error ? e.message : String(e))
} }
checkVersion(data.macroplan_version)
const features = asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
)
requireUniqueFeatureNames(features)
return { return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan', title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, 'plan', 'start') : undefined, start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, 'plan', 'end') : undefined, end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features: asBlocks(data.feature, 'feature').map((f, i) => features,
check(FeatureSchema, f, blockWhere('feature', f, i)), milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
), check(MilestoneSchema, m, blockWhere("milestone", m, i)),
milestones: asBlocks(data.milestone, 'milestone').map((m, i) =>
check(MilestoneSchema, m, blockWhere('milestone', m, i)),
), ),
} }
} }
/** Validate the optional `macroplan_version` marker. Absent means the current
* version; a future version we don't understand is rejected rather than
* silently mis-rendered. */
function checkVersion(value: unknown): void {
if (value == null) return
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new PlanParseError("`macroplan_version` must be a positive integer")
}
if (value > FORMAT_VERSION) {
throw new PlanParseError(
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
)
}
}
/** Feature names are the join key for a Milestone's `requires`, so they must be
* unique — otherwise a requirement resolves ambiguously to the first match. */
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
const seen = new Set<string>()
for (const f of features) {
if (seen.has(f.name)) {
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
}
seen.add(f.name)
}
}
function asBlocks(value: unknown, key: string): unknown[] { function asBlocks(value: unknown, key: string): unknown[] {
if (value == null) return [] if (value == null) return []
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@@ -75,10 +115,10 @@ function asBlocks(value: unknown, key: string): unknown[] {
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */ /** "feature \"Payments\"" when the block carries a name, else "feature #2". */
function blockWhere(kind: string, block: unknown, i: number): string { function blockWhere(kind: string, block: unknown, i: number): string {
const name = const name =
block != null && typeof block === 'object' && 'name' in block block != null && typeof block === "object" && "name" in block
? (block as { name: unknown }).name ? (block as { name: unknown }).name
: undefined : undefined
return name != null && name !== '' ? `${kind} "${String(name)}"` : `${kind} #${i + 1}` return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
} }
/** Validate `value` against `schema`, raising a contextual PlanParseError. */ /** Validate `value` against `schema`, raising a contextual PlanParseError. */
@@ -106,7 +146,7 @@ type Issue = {
* "Invalid key" wording for that, which isn't fit to show the author). */ * "Invalid key" wording for that, which isn't fit to show the author). */
function friendly(issue: Issue, fallbackField?: string): string { function friendly(issue: Issue, fallbackField?: string): string {
const key = issue.path?.[0]?.key const key = issue.path?.[0]?.key
const field = typeof key === 'string' ? key : fallbackField const field = typeof key === "string" ? key : fallbackField
if (issue.received === 'undefined') return field ? `missing \`${field}\`` : 'missing value' if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
return field ? `\`${field}\` ${issue.message}` : issue.message return field ? `\`${field}\` ${issue.message}` : issue.message
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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