chore: format

This commit is contained in:
Julien Calixte
2026-06-19 17:30:34 +02:00
parent 0ac212a4ac
commit 120a329421
30 changed files with 692 additions and 665 deletions

View File

@@ -1,6 +1,6 @@
# Macroplan
A **Macroplan** is a week-by-week plan that keeps the receipts: what we promised to deliver, when it actually landed, and what the gap taught us — a simpler cousin of a Gantt chart whose purpose is not just scheduling but *learning*.
A **Macroplan** is a week-by-week plan that keeps the receipts: what we promised to deliver, when it actually landed, and what the gap taught us — a simpler cousin of a Gantt chart whose purpose is not just scheduling but _learning_.
## Language
@@ -33,7 +33,7 @@ A free-text takeaway captured against a Feature once it is delivered — what th
_Avoid_: retro note, lesson, postmortem
**Status**:
A Feature's *current* delivery confidence (a snapshot, overwritten each review): **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
**Week**:

175
DESIGN.md
View File

@@ -1,6 +1,6 @@
# 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.
@@ -8,115 +8,116 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
## 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 |
| 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 |
| 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.
- **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_
- **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_
- **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_
- **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_
- **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_
- **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
- **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 | — |
| 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 |
| | 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.)
**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** | | | | | | | — |
| | 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 × 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).
- **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 |
| | 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.
@@ -429,35 +430,35 @@ Basement rows (top→bottom): **target · difficulty (15) · absolute weight
## 7. Critical performance budget
| Rank | Function | Target | Watched on | If we miss it |
|------|----------|--------|------------|---------------|
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
| 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" |
| Rank | Function | Target | Watched on | If we miss it |
| ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |
## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|----|----------|-----|------|-----|
| T1 | DOM/CSS Grid render over monospace text block | sticky panes, real colors, hover, image export | can't paste the plan as plain text; more layout code | — |
| T2 | TOML over a bespoke terse DSL (and over YAML) | robustness, native date literals, no parser to maintain, forgiving for a non-expert | verbose per Feature; source doesn't *look like* the plan | ADR-0002 |
| T3 | In-app split editor over Vite-HMR-on-file | instant loop, no toolchain for readers, works from static build | plan isn't a plain repo file by default (mitigated by Import/Export) | ADR-0002 |
| T4 | Image export over hosted URL | no data-hosting problem, paste into Slack/decks | snapshot loses hover content (status notes) | — |
| T5 | In-app plan library over single-document | switch plans without file juggling | list/CRUD UI, naming, localStorage to manage | ADR-0002 |
| ID | Tradeoff | Got | Paid | ADR |
| --- | --------------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- |
| T1 | DOM/CSS Grid render over monospace text block | sticky panes, real colors, hover, image export | can't paste the plan as plain text; more layout code | — |
| T2 | TOML over a bespoke terse DSL (and over YAML) | robustness, native date literals, no parser to maintain, forgiving for a non-expert | verbose per Feature; source doesn't _look like_ the plan | ADR-0002 |
| T3 | In-app split editor over Vite-HMR-on-file | instant loop, no toolchain for readers, works from static build | plan isn't a plain repo file by default (mitigated by Import/Export) | ADR-0002 |
| T4 | Image export over hosted URL | no data-hosting problem, paste into Slack/decks | snapshot loses hover content (status notes) | — |
| T5 | In-app plan library over single-document | switch plans without file juggling | list/CRUD UI, naming, localStorage to manage | ADR-0002 |
### Tensions being watched (unresolved by design)
- **Hover content vanishes in the exported image (F1↔F7).** For now we accept it: the colored status dot is always visible; only the *note* is lost. **Trigger to revisit:** if shared snapshots regularly need the notes, add an "expand notes inline before export" toggle.
- **Hover content vanishes in the exported image (F1↔F7).** For now we accept it: the colored status dot is always visible; only the _note_ is lost. **Trigger to revisit:** if shared snapshots regularly need the notes, add an "expand notes inline before export" toggle.
- **localStorage is the only live store (durability).** Accepted because Import/Export .toml is the durable, git-trackable backup. **Trigger to revisit:** first time a plan is lost, or when multi-device editing is needed → consider file-system-access API or a sync backend.
- **No multiplayer / real-time collaboration (v1).** Deliberately single-user, local-first per ADR-0002. A future version may add a **backend + database with a CRDT layer syncing edits to the same TOML** (the format and the pure Plan-model derivation are chosen to not block this). **Trigger to revisit:** when two people need to edit one Macroplan at the same time → spike a CRDT (e.g. Yjs) over the TOML document, behind the existing in-app editor.
## 9. Inconsistencies spotted and fixed
- **F8 was a solution, not a function.** "Keep names pinned while scrolling" named sticky panes (a How) and an outcome already in G1. Folded legibility-at-scale into F1's target; sticky panes became a How under F1.
- **F5 over-specified coverage.** Originally "Learning on 100% of delivered rows"; corrected — a Learning is *optional*, the function is low-friction capture, not coverage.
- **F5 over-specified coverage.** Originally "Learning on 100% of delivered rows"; corrected — a Learning is _optional_, the function is low-friction capture, not coverage.
- **F7 assumed hosting.** Originally "ship as a static hosted artifact"; a hosted URL has no data for a local-first file-based tool. Reframed to client-side image export, which actually serves G5 better.
- **F4 conflated terseness with ergonomics.** Originally "~1 feature = 1 line"; the real property for G4 is *local, ripple-free* CRUD. Reframed; terseness target dropped (TOML is not 1-line and that's fine).
- **F4 conflated terseness with ergonomics.** Originally "~1 feature = 1 line"; the real property for G4 is _local, ripple-free_ CRUD. Reframed; terseness target dropped (TOML is not 1-line and that's fine).
---

View File

@@ -1,6 +1,6 @@
# Macroplan
A week-by-week plan that keeps the receipts on what a team promised to deliver — a simpler cousin of a Gantt chart whose point is not just scheduling but learning from *how our estimates held up over time*.
A week-by-week plan that keeps the receipts on what a team promised to deliver — a simpler cousin of a Gantt chart whose point is not just scheduling but learning from _how our estimates held up over time_.
Each feature reads left-to-right as a story: where it started, what we first promised, every time it slipped, and when it actually shipped — judged honestly against the **first** estimate, never a moved goalpost.
@@ -30,9 +30,9 @@ Dashboard ┣━━━━━◯ 🔴 n
## Documentation
| Document | What it covers |
|----------|----------------|
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
| [docs/adr/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth |
| Document | What it covers |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
| [docs/adr/0002-local-first-no-backend.md](docs/adr/0002-local-first-no-backend.md) | Why the app is **local-first with no backend**, and TOML is the portable source of truth |

View File

@@ -29,12 +29,14 @@
Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename + download utility. Foundational and dependency-free.
**Files:**
- Modify: `src/composables/usePngExport.ts` (extract `slugify`, keep `exportFilename` output identical)
- Create: `src/composables/useSourceExport.ts`
- Test: `src/composables/useSourceExport.test.ts`
- (Unchanged, must still pass: `src/composables/usePngExport.test.ts`)
**Interfaces:**
- Produces: `slugify(title: string): string` (exported from `usePngExport.ts`) — lowercased, non-alphanumerics collapsed to dashes, edge dashes trimmed, **no** extension and **no** `plan` fallback.
- Produces: `sourceFilename(title: string): string``macroplan-<slug-or-plan>.toml`
- Produces: `downloadSource(source: string, filename: string): void`
@@ -44,17 +46,17 @@ Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename +
Create `src/composables/useSourceExport.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { sourceFilename } from './useSourceExport'
import { describe, it, expect } from "vitest"
import { sourceFilename } from "./useSourceExport"
describe('sourceFilename', () => {
it('slugifies the plan title into a .toml name', () => {
expect(sourceFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.toml')
describe("sourceFilename", () => {
it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.toml")
})
it('falls back to a generic name when the title has no usable characters', () => {
expect(sourceFilename('')).toBe('macroplan-plan.toml')
expect(sourceFilename('—— ··')).toBe('macroplan-plan.toml')
it("falls back to a generic name when the title has no usable characters", () => {
expect(sourceFilename("")).toBe("macroplan-plan.toml")
expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
})
})
```
@@ -74,31 +76,31 @@ export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
/** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string {
return `macroplan-${slugify(title) || 'plan'}.png`
return `macroplan-${slugify(title) || "plan"}.png`
}
```
- [ ] **Step 4: Create `useSourceExport.ts`**
```ts
import { slugify } from './usePngExport'
import { slugify } from "./usePngExport"
/** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string {
return `macroplan-${slugify(title) || 'plan'}.toml`
return `macroplan-${slugify(title) || "plan"}.toml`
}
/** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void {
const blob = new Blob([source], { type: 'text/plain;charset=utf-8' })
const blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
@@ -125,10 +127,12 @@ git commit -m "feat(export): add .toml source download and shared slugify helper
A header dropdown that lists plans, marks the active one, and emits switch / new / download intents. No storage or parsing logic — props in, events out.
**Files:**
- Create: `src/components/PlanSwitcher.vue`
- Test: `src/components/PlanSwitcher.test.ts`
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces: component `PlanSwitcher` with
- Props: `plans: { id: string; name: string }[]`, `activeId: string`
@@ -141,36 +145,36 @@ Create `src/components/PlanSwitcher.test.ts`:
```ts
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlanSwitcher from './PlanSwitcher.vue'
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import PlanSwitcher from "./PlanSwitcher.vue"
const plans = [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Bravo' },
{ id: "a", name: "Alpha" },
{ id: "b", name: "Bravo" },
]
describe('PlanSwitcher', () => {
it('lists plan names and marks the active one', () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'b' } })
expect(w.text()).toContain('Alpha')
expect(w.text()).toContain('Bravo')
expect(w.find('a.active').text()).toContain('Bravo')
describe("PlanSwitcher", () => {
it("lists plan names and marks the active one", () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
expect(w.text()).toContain("Alpha")
expect(w.text()).toContain("Bravo")
expect(w.find("a.active").text()).toContain("Bravo")
})
it('emits select with the clicked plan id', async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
await w.findAll('li a')[1].trigger('click') // Bravo
expect(w.emitted('select')?.[0]).toEqual(['b'])
it("emits select with the clicked plan id", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
await w.findAll("li a")[1].trigger("click") // Bravo
expect(w.emitted("select")?.[0]).toEqual(["b"])
})
it('emits new and download (with the active id) from the trailing actions', async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
const actions = w.findAll('li a')
await actions[actions.length - 2].trigger('click') // New plan
await actions[actions.length - 1].trigger('click') // Download .toml
expect(w.emitted('new')).toBeTruthy()
expect(w.emitted('download')?.[0]).toEqual(['a'])
it("emits new and download (with the active id) from the trailing actions", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
const actions = w.findAll("li a")
await actions[actions.length - 2].trigger("click") // New plan
await actions[actions.length - 1].trigger("click") // Download .toml
expect(w.emitted("new")).toBeTruthy()
expect(w.emitted("download")?.[0]).toEqual(["a"])
})
})
```
@@ -184,7 +188,7 @@ Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
```vue
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
@@ -198,7 +202,7 @@ const emit = defineEmits<{
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>
@@ -245,10 +249,12 @@ git commit -m "feat(ui): add the plan switcher dropdown"
Turn the single-source composable into the Library owner: storage + migration + active-plan binding + autosave, plus the switch/create/delete operations. Keep `resetToSample` exported for now so `App.vue` still compiles — Task 4 removes it.
**Files:**
- Modify (rewrite): `src/composables/useMacroplan.ts`
- Test: `src/composables/useMacroplan.test.ts`
**Interfaces:**
- Consumes: `parseMacroplan` (throws `PlanParseError`; defaults `title` to `'Untitled Macroplan'`), `buildPlan`, `SAMPLE_PLAN` (its title is `"Q3 — Checkout revamp"`).
- Produces (the composable's return shape, relied on by Task 4):
- `source: WritableComputedRef<string>` — reads/writes the active plan's source
@@ -259,7 +265,7 @@ Turn the single-source composable into the Library owner: storage + migration +
- `selectPlan(id: string): void`
- `newPlan(): void`
- `deletePlan(id: string): void`
- `resetToSample(): void` *(temporary — removed in Task 4)*
- `resetToSample(): void` _(temporary — removed in Task 4)_
- [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
@@ -267,18 +273,18 @@ Create `src/composables/useMacroplan.test.ts`:
```ts
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { useMacroplan } from './useMacroplan'
import { SAMPLE_PLAN } from '../data/sample'
import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from "vue"
import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = 'macroplan:library'
const LEGACY_KEY = 'macroplan:source'
const LIB_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear())
describe('useMacroplan — load & migration', () => {
it('seeds one sample plan when storage is empty, and persists it immediately', () => {
describe("useMacroplan — load & migration", () => {
it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id)
@@ -286,59 +292,59 @@ describe('useMacroplan — load & migration', () => {
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
})
it('migrates a legacy single-source store into a one-plan library and drops the legacy key', () => {
it("migrates a legacy single-source store into a one-plan library and drops the legacy key", () => {
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp')
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp")
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
})
it('falls back to a fresh sample when the library JSON is corrupt', () => {
localStorage.setItem(LIB_KEY, '{ not valid json')
it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('repairs a stale activeId instead of discarding the stored plans', () => {
it("repairs a stale activeId instead of discarding the stored plans", () => {
localStorage.setItem(
LIB_KEY,
JSON.stringify({
version: 1,
activeId: 'gone',
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }],
activeId: "gone",
plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}),
)
const m = useMacroplan()
expect(m.activeId.value).toBe('x')
expect(m.plans.value[0].name).toBe('Kept')
expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe("Kept")
})
})
describe('useMacroplan — active plan binding', () => {
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => {
describe("useMacroplan — active plan binding", () => {
it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan()
m.source.value = 'title = "Renamed"\n'
await nextTick()
expect(m.plans.value[0].name).toBe('Renamed')
expect(localStorage.getItem(LIB_KEY)).toContain('Renamed')
expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain("Renamed")
})
it('keeps the last-good name and render when the source is mid-edit/broken', async () => {
it("keeps the last-good name and render when the source is mid-edit/broken", async () => {
const m = useMacroplan()
const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick()
expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp') // name unchanged
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe('useMacroplan — CRUD', () => {
it('newPlan appends a sample plan and activates it', () => {
describe("useMacroplan — CRUD", () => {
it("newPlan appends a sample plan and activates it", () => {
const m = useMacroplan()
const firstId = m.activeId.value
m.newPlan()
@@ -347,7 +353,7 @@ describe('useMacroplan — CRUD', () => {
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('deletePlan removes the active plan and re-points to the preceding one', () => {
it("deletePlan removes the active plan and re-points to the preceding one", () => {
const m = useMacroplan()
m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value
@@ -356,14 +362,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id)
})
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
it("deleting the last plan re-seeds a fresh sample (never empty)", () => {
const m = useMacroplan()
m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('switching to a broken plan shows its own empty state, not the previous render', async () => {
it("switching to a broken plan shows its own empty state, not the previous render", async () => {
const m = useMacroplan()
const aId = m.activeId.value
m.newPlan()
@@ -391,14 +397,14 @@ Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not e
Replace the entire file with:
```ts
import { ref, computed, watch } from 'vue'
import { parseMacroplan } from '../model/parse'
import { buildPlan } from '../model/plan'
import type { Plan } from '../model/types'
import { SAMPLE_PLAN } from '../data/sample'
import { ref, computed, watch } from "vue"
import { parseMacroplan } from "../model/parse"
import { buildPlan } from "../model/plan"
import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = 'macroplan:library'
const LEGACY_KEY = 'macroplan:source'
const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
export interface StoredPlan {
id: string
@@ -423,7 +429,7 @@ function titleOf(source: string): string | null {
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
@@ -578,10 +584,12 @@ git commit -m "feat(model): make useMacroplan own a library of named plans"
Replace the static title block with the switcher, add a trash button + confirm modal that deletes the active plan, wire the `.toml` download, and remove the now-retired `resetToSample`.
**Files:**
- Modify (rewrite): `src/App.vue`
- Modify: `src/composables/useMacroplan.ts` (delete the temporary `resetToSample`)
**Interfaces:**
- Consumes: `useMacroplan` (`source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan`), `PlanSwitcher`, `sourceFilename` + `downloadSource`, `exportFilename` + `usePngExport`.
- [ ] **Step 1: Remove the temporary `resetToSample` from `useMacroplan.ts`**
@@ -601,13 +609,13 @@ Replace the entire file with:
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMacroplan } from './composables/useMacroplan'
import { usePngExport, exportFilename } from './composables/usePngExport'
import { sourceFilename, downloadSource } from './composables/useSourceExport'
import PlanEditor from './components/PlanEditor.vue'
import MacroplanGrid from './components/MacroplanGrid.vue'
import PlanSwitcher from './components/PlanSwitcher.vue'
import { ref, computed } from "vue"
import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport()
@@ -616,7 +624,7 @@ const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {
@@ -659,7 +667,11 @@ function confirmDelete() {
>
Download
</button>
<button class="btn btn-ghost btn-sm" title="Delete this plan" @click="confirmingDelete = true">
<button
class="btn btn-ghost btn-sm"
title="Delete this plan"
@click="confirmingDelete = true"
>
🗑
</button>
</header>
@@ -737,6 +749,7 @@ Expected: PASS — all suites (existing + the new filename, switcher, and librar
- [ ] **Step 5: Manual smoke check in the dev server**
Run: `pnpm dev` and open the app, then verify each:
1. The header shows the current plan name in a dropdown (no more static title).
2. Open the dropdown → ** New plan** adds a plan and switches to it (editor shows the sample again).
3. Edit the `title` in the source → the dropdown label updates to the new title.
@@ -762,6 +775,7 @@ git commit -m "feat(ui): wire the plan switcher, delete confirm and .toml downlo
Record the new `Library` term in the ubiquitous language and flip the README now that the library exists.
**Files:**
- Modify: `CONTEXT.md`
- Modify: `README.md`
@@ -770,7 +784,6 @@ Record the new `Library` term in the ubiquitous language and flip the README now
In the `## Language` section, immediately after the **Now line** entry (the block ending `_Avoid_: today marker, cursor`) and before `## Symbols`, insert:
```markdown
**Library**:
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
_Avoid_: workspace, project, file list
@@ -829,6 +842,7 @@ git commit -m "docs: record the Library term and mark the plans library as built
## Self-Review
**1. Spec coverage**
- Multiple named plans in localStorage → Task 3 (`Library` model).
- Header dropdown switcher → Task 2 (`PlanSwitcher`) + Task 4 (wiring).
- New plan from sample → Task 3 (`newPlan`) + Task 4.

View File

@@ -2,7 +2,7 @@
**Date:** 2026-06-17
**Status:** Approved (brainstorming) → ready for implementation plan
**Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — *"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."*
**Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — _"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."_
## Problem
@@ -13,6 +13,7 @@ This adds the library: keep several named Macroplans, switch between them, creat
## Scope
**In:**
- Multiple named Macroplans persisted in localStorage.
- A header dropdown to switch the active plan.
- Create a new plan (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.
**Out (YAGNI for v1):**
- Duplicate / clone a plan (fork via *New* + paste).
- File-open `.toml` *import* — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy.
- Duplicate / clone a plan (fork via _New_ + paste).
- File-open `.toml` _import_ — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy.
- Reordering, folders, search, tags.
- Any backend or multi-device sync (explicitly rejected by ADR-0002).
@@ -46,11 +48,11 @@ macroplan:library → {
}
```
| Field | Meaning |
|-------|---------|
| `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. |
| `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. |
| `source` | The TOML string the editor binds to. |
| Field | Meaning |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. |
| `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. |
| `source` | The TOML string the editor binds to. |
**Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3.
@@ -76,16 +78,16 @@ Holds `plans` and `activeId`; persists the whole library to `macroplan:library`
Exposes:
| Member | Type | Notes |
|--------|------|-------|
| `source` | writable `Ref<string>` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. |
| `plan` | `ComputedRef<Plan \| null>` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). |
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
| `activeId` | `Ref<string>` | Current selection. |
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. |
| `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). |
| `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
| Member | Type | Notes |
| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source` | writable `Ref<string>` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. |
| `plan` | `ComputedRef<Plan \| null>` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). |
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
| `activeId` | `Ref<string>` | Current selection. |
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. |
| `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). |
| `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
`resetToSample` is **removed**`newPlan()` (a blank page) replaces it.
@@ -133,6 +135,7 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
## Testing (Vitest, matching the existing suite)
**`useMacroplan` / library model:**
- No storage → library seeds one sample plan, active.
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
- Corrupt `macroplan:library` → seeds fresh sample (no throw).
@@ -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).
**Filename util:**
- `sourceFilename(title)` slug mirrors the existing `exportFilename` test (e.g. `"Q3 Delivery"``macroplan-q3-delivery.toml`); empty/garbage title → `macroplan-plan.toml`.
**Component (`@vue/test-utils`):**
- `PlanSwitcher` renders the plan names, marks the active one, and emits `select` / `new` / `delete` / `download` on the right interactions.
## Docs to update (in the implementation plan)
- **`CONTEXT.md`** — add the **Library** glossary entry above.
- **`README.md`** — flip the *"Not yet built: a library …"* note now that it exists; the *How it works* localStorage bullet can mention named plans + `.toml` export.
- **`README.md`** — flip the _"Not yet built: a library …"_ note now that it exists; the _How it works_ localStorage bullet can mention named plans + `.toml` export.
## Open questions

View File

@@ -1,7 +1,7 @@
{
"name": "macroplan",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",

View File

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

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMacroplan } from './composables/useMacroplan'
import { usePngExport, exportFilename } from './composables/usePngExport'
import { sourceFilename, downloadSource } from './composables/useSourceExport'
import PlanEditor from './components/PlanEditor.vue'
import MacroplanGrid from './components/MacroplanGrid.vue'
import PlanSwitcher from './components/PlanSwitcher.vue'
import { ref, computed } from "vue"
import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport()
@@ -14,7 +14,7 @@ const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import type { HighlighterCore } from 'shiki/core'
import { getCompletions, type CompletionContext } from './completion'
import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from "./completion"
const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>()
@@ -25,10 +25,10 @@ onMounted(async () => {
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
const [core, engine, toml, theme] = await Promise.all([
import('shiki/core'),
import('shiki/engine/javascript'),
import('shiki/langs/toml.mjs'),
import('shiki/themes/github-light.mjs'),
import("shiki/core"),
import("shiki/engine/javascript"),
import("shiki/langs/toml.mjs"),
import("shiki/themes/github-light.mjs"),
])
highlighter.value = await core.createHighlighterCore({
themes: [theme.default],
@@ -41,11 +41,11 @@ function measure() {
const el = textarea.value
if (!el) return
const cs = getComputedStyle(el)
const ctx = document.createElement('canvas').getContext('2d')!
const ctx = document.createElement("canvas").getContext("2d")!
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
metrics = {
charWidth: ctx.measureText('0000000000').width / 10,
charWidth: ctx.measureText("0000000000").width / 10,
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
padLeft: parseFloat(cs.paddingLeft),
padTop: parseFloat(cs.paddingTop),
@@ -66,10 +66,10 @@ function position() {
const el = textarea.value
if (!el) return
const before = el.value.slice(0, el.selectionStart)
const lineIndex = before.split('\n').length - 1
const line = before.slice(before.lastIndexOf('\n') + 1)
const lineIndex = before.split("\n").length - 1
const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
popup.value = {
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
@@ -83,7 +83,7 @@ function accept(i: number) {
const item = ctx.items[i]
const caret = ctx.from + item.insert.length
completion.value = null
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
emit("update:modelValue", el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
nextTick(() => {
el.setSelectionRange(caret, caret)
el.focus()
@@ -92,7 +92,7 @@ function accept(i: number) {
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') justEscaped = false
if (e.key !== "Escape") justEscaped = false
const ctx = completion.value
if (!ctx) return
const move = (delta: number) => {
@@ -100,32 +100,32 @@ function onKeydown(e: KeyboardEvent) {
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)
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
move(e.key === "n" ? 1 : -1)
return
}
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
move(1)
break
case 'ArrowUp':
case "ArrowUp":
move(-1)
break
case 'Enter':
case 'Tab':
case "Enter":
case "Tab":
e.preventDefault()
accept(selected.value)
break
case 'Escape':
case "Escape":
e.preventDefault()
completion.value = null
justEscaped = true
break
// Caret jumps would leave the popup stranded at a stale spot — close it.
case 'ArrowLeft':
case 'ArrowRight':
case 'Home':
case 'End':
case "ArrowLeft":
case "ArrowRight":
case "Home":
case "End":
completion.value = null
break
}
@@ -133,18 +133,18 @@ function onKeydown(e: KeyboardEvent) {
const highlighted = computed(() => {
// a trailing newline needs a trailing char so the last backdrop line keeps height
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
const code = props.modelValue.endsWith("\n") ? props.modelValue + " " : props.modelValue
const hl = highlighter.value
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
return hl.codeToHtml(code, { lang: 'toml', theme: 'github-light' })
return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
})
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)
}
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
refresh()
}

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'
import { getCompletions } from './completion'
import { describe, it, expect } from "vitest"
import { getCompletions } from "./completion"
/** Split a `|`-marked string into (source, caret offset). */
function atCaret(marked: string): [string, number] {
const caret = marked.indexOf('|')
const caret = marked.indexOf("|")
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
}
@@ -13,85 +13,85 @@ function labelsAt(marked: string): string[] {
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
}
describe('completion context', () => {
it('offers plan keys and block headers on an empty top-level line', () => {
expect(labelsAt('|')).toEqual(['title', 'start', 'end', '[[feature]]', '[[milestone]]'])
describe("completion context", () => {
it("offers plan keys and block headers on an empty top-level line", () => {
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
})
it('offers feature keys inside a [[feature]] block', () => {
expect(labelsAt('[[feature]]\n|')).toEqual([
'name',
'start',
'original',
'reestimates',
'delivered',
'status',
'learning',
'note',
'[[feature]]',
'[[milestone]]',
it("offers feature keys inside a [[feature]] block", () => {
expect(labelsAt("[[feature]]\n|")).toEqual([
"name",
"start",
"original",
"reestimates",
"delivered",
"status",
"learning",
"note",
"[[feature]]",
"[[milestone]]",
])
})
it('offers milestone keys inside a [[milestone]] block', () => {
expect(labelsAt('[[milestone]]\n|')).toEqual([
'name',
'week',
'requires',
'[[feature]]',
'[[milestone]]',
it("offers milestone keys inside a [[milestone]] block", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual([
"name",
"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|')
expect(labels).not.toContain('name')
expect(labels).not.toContain('original')
expect(labels).toContain('start')
expect(labels).not.toContain("name")
expect(labels).not.toContain("original")
expect(labels).toContain("start")
})
it('filters keys by the typed prefix', () => {
expect(labelsAt('[[feature]]\nst|')).toEqual(['start', 'status'])
it("filters keys by the typed prefix", () => {
expect(labelsAt("[[feature]]\nst|")).toEqual(["start", "status"])
})
it('completes a header from a leading bracket', () => {
expect(labelsAt('[[f|')).toEqual(['[[feature]]'])
it("completes a header from a leading bracket", () => {
expect(labelsAt("[[f|")).toEqual(["[[feature]]"])
})
it('suggests status values after status =', () => {
expect(labelsAt('[[feature]]\nstatus = |')).toEqual(['on-track', 'at-risk', 'off-track'])
it("suggests status values after status =", () => {
expect(labelsAt("[[feature]]\nstatus = |")).toEqual(["on-track", "at-risk", "off-track"])
})
it('filters status values by the typed prefix, inside an open quote', () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(['on-track', 'off-track'])
it("filters status values by the typed prefix, inside an open quote", () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(["on-track", "off-track"])
})
it('suggests feature names inside a milestone requires array', () => {
it("suggests feature names inside a milestone requires array", () => {
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.
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
})
it('returns nothing in a value position it cannot complete (a date)', () => {
expect(getCompletions(...atCaret('[[feature]]\nstart = 2026-|'))).toBeNull()
it("returns nothing in a value position it cannot complete (a date)", () => {
expect(getCompletions(...atCaret("[[feature]]\nstart = 2026-|"))).toBeNull()
})
})
describe('completion replace range', () => {
it('replaces the typed prefix, not the whole line', () => {
const source = '[[feature]]\nst'
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')
expect(source.slice(ctx.from, ctx.to)).toBe("st")
})
it('replaces a partial bracketed header from the bracket', () => {
const source = '[[f'
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]]')
expect(source.slice(ctx.from, ctx.to)).toBe("[[f")
expect(ctx.items[0].insert).toBe("[[feature]]")
})
})

View File

@@ -21,41 +21,41 @@ export interface CompletionContext {
items: Completion[]
}
type Block = 'plan' | 'feature' | 'milestone'
type Block = "plan" | "feature" | "milestone"
const PLAN_KEYS: Completion[] = [
{ label: 'title', insert: 'title = ', detail: 'plan title' },
{ label: 'start', insert: 'start = ', detail: 'left edge — date, optional' },
{ label: 'end', insert: 'end = ', detail: 'right edge — date, optional' },
{ label: "title", insert: "title = ", detail: "plan title" },
{ label: "start", insert: "start = ", detail: "left edge — date, optional" },
{ label: "end", insert: "end = ", detail: "right edge — date, optional" },
]
const FEATURE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' },
{ label: 'start', insert: 'start = ', detail: 'date, required' },
{ label: 'original', insert: 'original = ', detail: 'Original Estimate — date, required' },
{ label: 'reestimates', insert: 'reestimates = ', detail: 'list of dates' },
{ label: 'delivered', insert: 'delivered = ', detail: 'date' },
{ label: 'status', insert: 'status = ', detail: 'on-track | at-risk | off-track' },
{ label: 'learning', insert: 'learning = ', detail: 'string' },
{ label: 'note', insert: 'note = ', detail: 'string' },
{ label: "name", insert: "name = ", detail: "required" },
{ label: "start", insert: "start = ", detail: "date, required" },
{ label: "original", insert: "original = ", detail: "Original Estimate — date, required" },
{ label: "reestimates", insert: "reestimates = ", detail: "list of dates" },
{ label: "delivered", insert: "delivered = ", detail: "date" },
{ label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" },
{ label: "learning", insert: "learning = ", detail: "string" },
{ label: "note", insert: "note = ", detail: "string" },
]
const MILESTONE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' },
{ label: 'week', insert: 'week = ', detail: 'date, required' },
{ label: 'requires', insert: 'requires = ', detail: 'list of feature names' },
{ label: "name", insert: "name = ", detail: "required" },
{ label: "week", insert: "week = ", detail: "date, required" },
{ label: "requires", insert: "requires = ", detail: "list of feature names" },
]
const HEADERS: Completion[] = [
{ label: '[[feature]]', insert: '[[feature]]', detail: 'new feature', filter: 'feature' },
{ label: '[[milestone]]', insert: '[[milestone]]', detail: 'new milestone', filter: 'milestone' },
{ label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" },
{ label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" },
]
const STATUSES = ['on-track', 'at-risk', 'off-track'] // keep in sync with parse.ts
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. */
export function getCompletions(source: string, caret: number): CompletionContext | null {
const lineStart = source.lastIndexOf('\n', caret - 1) + 1
const lineStart = source.lastIndexOf("\n", caret - 1) + 1
const linePrefix = source.slice(lineStart, caret)
// ── value: status = "<here>" ──────────────────────────────────────────────
@@ -71,7 +71,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
const block = currentBlock(source, lineStart)
// ── 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 items = filter(
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
@@ -88,7 +88,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
return result(lineStart + indent.length, caret, filter(HEADERS, word))
}
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 items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
return result(caret - word.length, caret, items)
@@ -111,7 +111,7 @@ const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm
/** The block owning the line at `lineStart` — 'plan' before the first header. */
function currentBlock(source: string, lineStart: number): Block {
let block: Block = 'plan'
let block: Block = "plan"
HEADER_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
@@ -129,7 +129,7 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
let start = 0
let end = source.length
if (block === 'plan') {
if (block === "plan") {
end = headers[0] ?? source.length
} else {
for (let i = 0; i < headers.length; i++) {
@@ -151,11 +151,11 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
function featureNames(source: string): string[] {
const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm
const names = new Set<string>()
let block: Block = 'plan'
let block: Block = "plan"
let m: RegExpExecArray | null
while ((m = re.exec(source)) !== null) {
if (m[1]) block = m[1] as Block
else if (m[2] && block === 'feature') names.add(m[2])
else if (m[2] && block === "feature") names.add(m[2])
}
return [...names]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,35 @@
import * as v from 'valibot'
import { parse as parseToml } from 'smol-toml'
import { toYmd } from './week'
import type { RawPlan, StatusLevel } from './types'
import * as v from "valibot"
import { parse as parseToml } from "smol-toml"
import { toYmd } from "./week"
import type { RawPlan, StatusLevel } from "./types"
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'PlanParseError'
this.name = "PlanParseError"
}
}
const STATUSES = ['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 ──────────────────────────────────────────────────────────
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
// normalized to yyyy-mm-dd via `toYmd`.
const Ymd = v.pipe(
v.union([v.date(), v.string()], 'must be a date (e.g. 2026-06-01)'),
v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
v.transform((value: string | Date) => toYmd(value)),
)
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(', ')}`)
const 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({
name: Name,
start: 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),
learning: v.optional(v.string()),
status: v.optional(Status),
@@ -39,7 +39,10 @@ const FeatureSchema = v.object({
const MilestoneSchema = v.object({
name: Name,
week: Ymd,
requires: v.optional(v.array(v.string('must be a feature name'), 'must be a list of feature names'), []),
requires: v.optional(
v.array(v.string("must be a feature name"), "must be a list of feature names"),
[],
),
})
/** Parse + validate a Macroplan TOML source into the raw model. */
@@ -52,14 +55,14 @@ export function parseMacroplan(source: string): RawPlan {
}
return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
start: data.start != null ? check(Ymd, data.start, 'plan', 'start') : undefined,
end: data.end != null ? check(Ymd, data.end, 'plan', 'end') : undefined,
features: asBlocks(data.feature, 'feature').map((f, i) =>
check(FeatureSchema, f, blockWhere('feature', f, i)),
title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features: asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
),
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)),
),
}
}
@@ -75,10 +78,10 @@ function asBlocks(value: unknown, key: string): unknown[] {
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
function blockWhere(kind: string, block: unknown, i: number): string {
const name =
block != null && typeof block === 'object' && 'name' in block
block != null && typeof block === "object" && "name" in block
? (block as { name: unknown }).name
: 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. */
@@ -106,7 +109,7 @@ type Issue = {
* "Invalid key" wording for that, which isn't fit to show the author). */
function friendly(issue: Issue, fallbackField?: string): string {
const key = issue.path?.[0]?.key
const field = typeof key === 'string' ? key : fallbackField
if (issue.received === 'undefined') return field ? `missing \`${field}\`` : 'missing value'
const field = typeof key === "string" ? key : fallbackField
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
return field ? `\`${field}\` ${issue.message}` : issue.message
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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