From 120a329421da3aa621516f73f81aa6dd32e9e63e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 19 Jun 2026 17:30:34 +0200 Subject: [PATCH] chore: format --- CONTEXT.md | 4 +- DESIGN.md | 175 ++++++++-------- README.md | 14 +- .../plans/2026-06-17-plans-library.md | 186 ++++++++++-------- .../specs/2026-06-17-plans-library-design.md | 43 ++-- package.json | 2 +- src/App.test.ts | 68 ++++--- src/App.vue | 16 +- src/assets/icons/README.md | 11 +- src/components/MacroplanGrid.test.ts | 52 ++--- src/components/MacroplanGrid.vue | 88 +++++---- src/components/PlanEditor.vue | 60 +++--- src/components/PlanSwitcher.test.ts | 44 ++--- src/components/PlanSwitcher.vue | 4 +- src/components/completion.test.ts | 100 +++++----- src/components/completion.ts | 50 ++--- src/composables/useMacroplan.test.ts | 62 +++--- src/composables/useMacroplan.ts | 20 +- src/composables/usePngExport.test.ts | 20 +- src/composables/usePngExport.ts | 34 ++-- src/composables/useSourceExport.test.ts | 16 +- src/composables/useSourceExport.ts | 8 +- src/main.ts | 8 +- src/model/parse.ts | 47 ++--- src/model/plan.test.ts | 178 ++++++++--------- src/model/plan.ts | 12 +- src/model/types.ts | 12 +- src/model/week.ts | 12 +- tsconfig.json | 5 +- vite.config.ts | 6 +- 30 files changed, 692 insertions(+), 665 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 88eb532..ea3c7c0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/DESIGN.md b/DESIGN.md index 6f70db9..238dfb5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 (1–5) · 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). --- diff --git a/README.md b/README.md index 7092d70..41a052e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/superpowers/plans/2026-06-17-plans-library.md b/docs/superpowers/plans/2026-06-17-plans-library.md index 01c20ab..5f952ea 100644 --- a/docs/superpowers/plans/2026-06-17-plans-library.md +++ b/docs/superpowers/plans/2026-06-17-plans-library.md @@ -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-.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 @@ -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` — 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 diff --git a/src/components/completion.test.ts b/src/components/completion.test.ts index bb448da..900e3ef 100644 --- a/src/components/completion.test.ts +++ b/src/components/completion.test.ts @@ -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]]") }) }) diff --git a/src/components/completion.ts b/src/components/completion.ts index ac1b637..5310820 100644 --- a/src/components/completion.ts +++ b/src/components/completion.ts @@ -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 = "" ────────────────────────────────────────────── @@ -71,7 +71,7 @@ export function getCompletions(source: string, caret: number): CompletionContext const block = currentBlock(source, lineStart) // ── value: requires = [ "" … ] (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() - 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] } diff --git a/src/composables/useMacroplan.test.ts b/src/composables/useMacroplan.test.ts index 1157f15..eb5ce83 100644 --- a/src/composables/useMacroplan.test.ts +++ b/src/composables/useMacroplan.test.ts @@ -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() diff --git a/src/composables/useMacroplan.ts b/src/composables/useMacroplan.ts index 55a79c9..6724ff7 100644 --- a/src/composables/useMacroplan.ts +++ b/src/composables/useMacroplan.ts @@ -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) }, diff --git a/src/composables/usePngExport.test.ts b/src/composables/usePngExport.test.ts index 9514ac2..5db04db 100644 --- a/src/composables/usePngExport.test.ts +++ b/src/composables/usePngExport.test.ts @@ -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") }) }) diff --git a/src/composables/usePngExport.ts b/src/composables/usePngExport.ts index c6adf14..a87c2f1 100644 --- a/src/composables/usePngExport.ts +++ b/src/composables/usePngExport.ts @@ -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(null) let timer: ReturnType | 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 { - 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, 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', 'Couldn’t copy — use Download instead') + flash("err", "Couldn’t 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 } diff --git a/src/composables/useSourceExport.test.ts b/src/composables/useSourceExport.test.ts index 5e9ba32..51bd2f2 100644 --- a/src/composables/useSourceExport.test.ts +++ b/src/composables/useSourceExport.test.ts @@ -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") }) }) diff --git a/src/composables/useSourceExport.ts b/src/composables/useSourceExport.ts index 328832e..942d7b3 100644 --- a/src/composables/useSourceExport.ts +++ b/src/composables/useSourceExport.ts @@ -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() diff --git a/src/main.ts b/src/main.ts index 2425c0f..8a1724e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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") diff --git a/src/model/parse.ts b/src/model/parse.ts index 4db5f85..e2b8a01 100644 --- a/src/model/parse.ts +++ b/src/model/parse.ts @@ -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 } diff --git a/src/model/plan.test.ts b/src/model/plan.test.ts index 4cf506a..2e3c38a 100644 --- a/src/model/plan.test.ts +++ b/src/model/plan.test.ts @@ -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>((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 Milestone’s unmet required Features (undelivered or delivered after the milestone)', () => { + it("flags a Milestone’s 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) diff --git a/src/model/plan.ts b/src/model/plan.ts index 5b28f15..f70fe7d 100644 --- a/src/model/plan.ts +++ b/src/model/plan.ts @@ -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)) diff --git a/src/model/types.ts b/src/model/types.ts index 63a1da6..8242113 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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 diff --git a/src/model/week.ts b/src/model/week.ts index add7257..b65c6c9 100644 --- a/src/model/week.ts +++ b/src/model/week.ts @@ -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", }) } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..d32ff68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 4815a12..6f5ecd8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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({