chore: format
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Macroplan
|
||||
|
||||
A **Macroplan** is a week-by-week plan that keeps the receipts: what we promised to deliver, when it actually landed, and what the gap taught us — a simpler cousin of a Gantt chart whose purpose is not just scheduling but *learning*.
|
||||
A **Macroplan** is a week-by-week plan that keeps the receipts: what we promised to deliver, when it actually landed, and what the gap taught us — a simpler cousin of a Gantt chart whose purpose is not just scheduling but _learning_.
|
||||
|
||||
## Language
|
||||
|
||||
@@ -33,7 +33,7 @@ A free-text takeaway captured against a Feature once it is delivered — what th
|
||||
_Avoid_: retro note, lesson, postmortem
|
||||
|
||||
**Status**:
|
||||
A Feature's *current* delivery confidence (a snapshot, overwritten each review): **on-track** (all good), **at-risk** (in trouble but we have a plan), **off-track** (in trouble and we have no plan). May carry a comment. Applies only while in-flight; once delivered, the **Learning** takes over and the Status is dropped. An overdue Feature (past its latest estimate, not delivered) is expressed through an at-risk/off-track Status, not a dedicated symbol.
|
||||
A Feature's _current_ delivery confidence (a snapshot, overwritten each review): **on-track** (all good), **at-risk** (in trouble but we have a plan), **off-track** (in trouble and we have no plan). May carry a comment. Applies only while in-flight; once delivered, the **Learning** takes over and the Status is dropped. An overdue Feature (past its latest estimate, not delivered) is expressed through an at-risk/off-track Status, not a dedicated symbol.
|
||||
_Avoid_: health, RAG, risk
|
||||
|
||||
**Week**:
|
||||
|
||||
175
DESIGN.md
175
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
README.md
14
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 |
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename + download utility. Foundational and dependency-free.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/composables/usePngExport.ts` (extract `slugify`, keep `exportFilename` output identical)
|
||||
- Create: `src/composables/useSourceExport.ts`
|
||||
- Test: `src/composables/useSourceExport.test.ts`
|
||||
- (Unchanged, must still pass: `src/composables/usePngExport.test.ts`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `slugify(title: string): string` (exported from `usePngExport.ts`) — lowercased, non-alphanumerics collapsed to dashes, edge dashes trimmed, **no** extension and **no** `plan` fallback.
|
||||
- Produces: `sourceFilename(title: string): string` → `macroplan-<slug-or-plan>.toml`
|
||||
- Produces: `downloadSource(source: string, filename: string): void`
|
||||
@@ -44,17 +46,17 @@ Extract a reusable `slugify` from the PNG exporter and add a `.toml` filename +
|
||||
Create `src/composables/useSourceExport.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sourceFilename } from './useSourceExport'
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { sourceFilename } from "./useSourceExport"
|
||||
|
||||
describe('sourceFilename', () => {
|
||||
it('slugifies the plan title into a .toml name', () => {
|
||||
expect(sourceFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.toml')
|
||||
describe("sourceFilename", () => {
|
||||
it("slugifies the plan title into a .toml name", () => {
|
||||
expect(sourceFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.toml")
|
||||
})
|
||||
|
||||
it('falls back to a generic name when the title has no usable characters', () => {
|
||||
expect(sourceFilename('')).toBe('macroplan-plan.toml')
|
||||
expect(sourceFilename('—— ··')).toBe('macroplan-plan.toml')
|
||||
it("falls back to a generic name when the title has no usable characters", () => {
|
||||
expect(sourceFilename("")).toBe("macroplan-plan.toml")
|
||||
expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
|
||||
})
|
||||
})
|
||||
```
|
||||
@@ -74,31 +76,31 @@ export function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
/** Slugified, stable download name derived from the plan title. */
|
||||
export function exportFilename(title: string): string {
|
||||
return `macroplan-${slugify(title) || 'plan'}.png`
|
||||
return `macroplan-${slugify(title) || "plan"}.png`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `useSourceExport.ts`**
|
||||
|
||||
```ts
|
||||
import { slugify } from './usePngExport'
|
||||
import { slugify } from "./usePngExport"
|
||||
|
||||
/** Slugified, stable download name for a plan's TOML source. */
|
||||
export function sourceFilename(title: string): string {
|
||||
return `macroplan-${slugify(title) || 'plan'}.toml`
|
||||
return `macroplan-${slugify(title) || "plan"}.toml`
|
||||
}
|
||||
|
||||
/** Download a plan's TOML source as a .toml file (client-side, no backend). */
|
||||
export function downloadSource(source: string, filename: string): void {
|
||||
const blob = new Blob([source], { type: 'text/plain;charset=utf-8' })
|
||||
const blob = new Blob([source], { type: "text/plain;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
@@ -125,10 +127,12 @@ git commit -m "feat(export): add .toml source download and shared slugify helper
|
||||
A header dropdown that lists plans, marks the active one, and emits switch / new / download intents. No storage or parsing logic — props in, events out.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/components/PlanSwitcher.vue`
|
||||
- Test: `src/components/PlanSwitcher.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing from other tasks.
|
||||
- Produces: component `PlanSwitcher` with
|
||||
- Props: `plans: { id: string; name: string }[]`, `activeId: string`
|
||||
@@ -141,36 +145,36 @@ Create `src/components/PlanSwitcher.test.ts`:
|
||||
|
||||
```ts
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlanSwitcher from './PlanSwitcher.vue'
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { mount } from "@vue/test-utils"
|
||||
import PlanSwitcher from "./PlanSwitcher.vue"
|
||||
|
||||
const plans = [
|
||||
{ id: 'a', name: 'Alpha' },
|
||||
{ id: 'b', name: 'Bravo' },
|
||||
{ id: "a", name: "Alpha" },
|
||||
{ id: "b", name: "Bravo" },
|
||||
]
|
||||
|
||||
describe('PlanSwitcher', () => {
|
||||
it('lists plan names and marks the active one', () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'b' } })
|
||||
expect(w.text()).toContain('Alpha')
|
||||
expect(w.text()).toContain('Bravo')
|
||||
expect(w.find('a.active').text()).toContain('Bravo')
|
||||
describe("PlanSwitcher", () => {
|
||||
it("lists plan names and marks the active one", () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
|
||||
expect(w.text()).toContain("Alpha")
|
||||
expect(w.text()).toContain("Bravo")
|
||||
expect(w.find("a.active").text()).toContain("Bravo")
|
||||
})
|
||||
|
||||
it('emits select with the clicked plan id', async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
|
||||
await w.findAll('li a')[1].trigger('click') // Bravo
|
||||
expect(w.emitted('select')?.[0]).toEqual(['b'])
|
||||
it("emits select with the clicked plan id", async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
|
||||
await w.findAll("li a")[1].trigger("click") // Bravo
|
||||
expect(w.emitted("select")?.[0]).toEqual(["b"])
|
||||
})
|
||||
|
||||
it('emits new and download (with the active id) from the trailing actions', async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
|
||||
const actions = w.findAll('li a')
|
||||
await actions[actions.length - 2].trigger('click') // New plan
|
||||
await actions[actions.length - 1].trigger('click') // Download .toml
|
||||
expect(w.emitted('new')).toBeTruthy()
|
||||
expect(w.emitted('download')?.[0]).toEqual(['a'])
|
||||
it("emits new and download (with the active id) from the trailing actions", async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
|
||||
const actions = w.findAll("li a")
|
||||
await actions[actions.length - 2].trigger("click") // New plan
|
||||
await actions[actions.length - 1].trigger("click") // Download .toml
|
||||
expect(w.emitted("new")).toBeTruthy()
|
||||
expect(w.emitted("download")?.[0]).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
```
|
||||
@@ -184,7 +188,7 @@ Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
plans: { id: string; name: string }[]
|
||||
@@ -198,7 +202,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const activeName = computed(
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -245,10 +249,12 @@ git commit -m "feat(ui): add the plan switcher dropdown"
|
||||
Turn the single-source composable into the Library owner: storage + migration + active-plan binding + autosave, plus the switch/create/delete operations. Keep `resetToSample` exported for now so `App.vue` still compiles — Task 4 removes it.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify (rewrite): `src/composables/useMacroplan.ts`
|
||||
- Test: `src/composables/useMacroplan.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `parseMacroplan` (throws `PlanParseError`; defaults `title` to `'Untitled Macroplan'`), `buildPlan`, `SAMPLE_PLAN` (its title is `"Q3 — Checkout revamp"`).
|
||||
- Produces (the composable's return shape, relied on by Task 4):
|
||||
- `source: WritableComputedRef<string>` — reads/writes the active plan's source
|
||||
@@ -259,7 +265,7 @@ Turn the single-source composable into the Library owner: storage + migration +
|
||||
- `selectPlan(id: string): void`
|
||||
- `newPlan(): void`
|
||||
- `deletePlan(id: string): void`
|
||||
- `resetToSample(): void` *(temporary — removed in Task 4)*
|
||||
- `resetToSample(): void` _(temporary — removed in Task 4)_
|
||||
|
||||
- [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
|
||||
|
||||
@@ -267,18 +273,18 @@ Create `src/composables/useMacroplan.test.ts`:
|
||||
|
||||
```ts
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useMacroplan } from './useMacroplan'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { nextTick } from "vue"
|
||||
import { useMacroplan } from "./useMacroplan"
|
||||
import { SAMPLE_PLAN } from "../data/sample"
|
||||
|
||||
const LIB_KEY = 'macroplan:library'
|
||||
const LEGACY_KEY = 'macroplan:source'
|
||||
const LIB_KEY = "macroplan:library"
|
||||
const LEGACY_KEY = "macroplan:source"
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('useMacroplan — load & migration', () => {
|
||||
it('seeds one sample plan when storage is empty, and persists it immediately', () => {
|
||||
describe("useMacroplan — load & migration", () => {
|
||||
it("seeds one sample plan when storage is empty, and persists it immediately", () => {
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(m.plans.value[0].id)
|
||||
@@ -286,59 +292,59 @@ describe('useMacroplan — load & migration', () => {
|
||||
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
|
||||
})
|
||||
|
||||
it('migrates a legacy single-source store into a one-plan library and drops the legacy key', () => {
|
||||
it("migrates a legacy single-source store into a one-plan library and drops the legacy key", () => {
|
||||
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp')
|
||||
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp")
|
||||
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a fresh sample when the library JSON is corrupt', () => {
|
||||
localStorage.setItem(LIB_KEY, '{ not valid json')
|
||||
it("falls back to a fresh sample when the library JSON is corrupt", () => {
|
||||
localStorage.setItem(LIB_KEY, "{ not valid json")
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('repairs a stale activeId instead of discarding the stored plans', () => {
|
||||
it("repairs a stale activeId instead of discarding the stored plans", () => {
|
||||
localStorage.setItem(
|
||||
LIB_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'gone',
|
||||
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }],
|
||||
activeId: "gone",
|
||||
plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
|
||||
}),
|
||||
)
|
||||
const m = useMacroplan()
|
||||
expect(m.activeId.value).toBe('x')
|
||||
expect(m.plans.value[0].name).toBe('Kept')
|
||||
expect(m.activeId.value).toBe("x")
|
||||
expect(m.plans.value[0].name).toBe("Kept")
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMacroplan — active plan binding', () => {
|
||||
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => {
|
||||
describe("useMacroplan — active plan binding", () => {
|
||||
it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
|
||||
const m = useMacroplan()
|
||||
m.source.value = 'title = "Renamed"\n'
|
||||
await nextTick()
|
||||
expect(m.plans.value[0].name).toBe('Renamed')
|
||||
expect(localStorage.getItem(LIB_KEY)).toContain('Renamed')
|
||||
expect(m.plans.value[0].name).toBe("Renamed")
|
||||
expect(localStorage.getItem(LIB_KEY)).toContain("Renamed")
|
||||
})
|
||||
|
||||
it('keeps the last-good name and render when the source is mid-edit/broken', async () => {
|
||||
it("keeps the last-good name and render when the source is mid-edit/broken", async () => {
|
||||
const m = useMacroplan()
|
||||
const goodPlan = m.plan.value
|
||||
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
|
||||
await nextTick()
|
||||
expect(m.error.value).toBeTruthy()
|
||||
expect(m.plan.value).toBe(goodPlan) // last-good render retained
|
||||
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp') // name unchanged
|
||||
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMacroplan — CRUD', () => {
|
||||
it('newPlan appends a sample plan and activates it', () => {
|
||||
describe("useMacroplan — CRUD", () => {
|
||||
it("newPlan appends a sample plan and activates it", () => {
|
||||
const m = useMacroplan()
|
||||
const firstId = m.activeId.value
|
||||
m.newPlan()
|
||||
@@ -347,7 +353,7 @@ describe('useMacroplan — CRUD', () => {
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('deletePlan removes the active plan and re-points to the preceding one', () => {
|
||||
it("deletePlan removes the active plan and re-points to the preceding one", () => {
|
||||
const m = useMacroplan()
|
||||
m.newPlan() // 2 plans; second is active
|
||||
const [first, second] = m.plans.value
|
||||
@@ -356,14 +362,14 @@ describe('useMacroplan — CRUD', () => {
|
||||
expect(m.activeId.value).toBe(first.id)
|
||||
})
|
||||
|
||||
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
|
||||
it("deleting the last plan re-seeds a fresh sample (never empty)", () => {
|
||||
const m = useMacroplan()
|
||||
m.deletePlan(m.activeId.value)
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
it('switching to a broken plan shows its own empty state, not the previous render', async () => {
|
||||
it("switching to a broken plan shows its own empty state, not the previous render", async () => {
|
||||
const m = useMacroplan()
|
||||
const aId = m.activeId.value
|
||||
m.newPlan()
|
||||
@@ -391,14 +397,14 @@ Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not e
|
||||
Replace the entire file with:
|
||||
|
||||
```ts
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { parseMacroplan } from '../model/parse'
|
||||
import { buildPlan } from '../model/plan'
|
||||
import type { Plan } from '../model/types'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { parseMacroplan } from "../model/parse"
|
||||
import { buildPlan } from "../model/plan"
|
||||
import type { Plan } from "../model/types"
|
||||
import { SAMPLE_PLAN } from "../data/sample"
|
||||
|
||||
const STORAGE_KEY = 'macroplan:library'
|
||||
const LEGACY_KEY = 'macroplan:source'
|
||||
const STORAGE_KEY = "macroplan:library"
|
||||
const LEGACY_KEY = "macroplan:source"
|
||||
|
||||
export interface StoredPlan {
|
||||
id: string
|
||||
@@ -423,7 +429,7 @@ function titleOf(source: string): string | null {
|
||||
}
|
||||
|
||||
function newStoredPlan(source: string): StoredPlan {
|
||||
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
|
||||
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
|
||||
}
|
||||
|
||||
function save(lib: Library): void {
|
||||
@@ -578,10 +584,12 @@ git commit -m "feat(model): make useMacroplan own a library of named plans"
|
||||
Replace the static title block with the switcher, add a trash button + confirm modal that deletes the active plan, wire the `.toml` download, and remove the now-retired `resetToSample`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify (rewrite): `src/App.vue`
|
||||
- Modify: `src/composables/useMacroplan.ts` (delete the temporary `resetToSample`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `useMacroplan` (`source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan`), `PlanSwitcher`, `sourceFilename` + `downloadSource`, `exportFilename` + `usePngExport`.
|
||||
|
||||
- [ ] **Step 1: Remove the temporary `resetToSample` from `useMacroplan.ts`**
|
||||
@@ -601,13 +609,13 @@ Replace the entire file with:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMacroplan } from './composables/useMacroplan'
|
||||
import { usePngExport, exportFilename } from './composables/usePngExport'
|
||||
import { sourceFilename, downloadSource } from './composables/useSourceExport'
|
||||
import PlanEditor from './components/PlanEditor.vue'
|
||||
import MacroplanGrid from './components/MacroplanGrid.vue'
|
||||
import PlanSwitcher from './components/PlanSwitcher.vue'
|
||||
import { ref, computed } from "vue"
|
||||
import { useMacroplan } from "./composables/useMacroplan"
|
||||
import { usePngExport, exportFilename } from "./composables/usePngExport"
|
||||
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
|
||||
import PlanEditor from "./components/PlanEditor.vue"
|
||||
import MacroplanGrid from "./components/MacroplanGrid.vue"
|
||||
import PlanSwitcher from "./components/PlanSwitcher.vue"
|
||||
|
||||
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
|
||||
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
||||
@@ -616,7 +624,7 @@ const exportRoot = ref<HTMLElement>()
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
const activeName = computed(
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
|
||||
)
|
||||
|
||||
function downloadToml() {
|
||||
@@ -659,7 +667,11 @@ function confirmDelete() {
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Delete this plan" @click="confirmingDelete = true">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
title="Delete this plan"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</header>
|
||||
@@ -737,6 +749,7 @@ Expected: PASS — all suites (existing + the new filename, switcher, and librar
|
||||
- [ ] **Step 5: Manual smoke check in the dev server**
|
||||
|
||||
Run: `pnpm dev` and open the app, then verify each:
|
||||
|
||||
1. The header shows the current plan name in a dropdown (no more static title).
|
||||
2. Open the dropdown → **+ New plan** adds a plan and switches to it (editor shows the sample again).
|
||||
3. Edit the `title` in the source → the dropdown label updates to the new title.
|
||||
@@ -762,6 +775,7 @@ git commit -m "feat(ui): wire the plan switcher, delete confirm and .toml downlo
|
||||
Record the new `Library` term in the ubiquitous language and flip the README now that the library exists.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `CONTEXT.md`
|
||||
- Modify: `README.md`
|
||||
|
||||
@@ -770,7 +784,6 @@ Record the new `Library` term in the ubiquitous language and flip the README now
|
||||
In the `## Language` section, immediately after the **Now line** entry (the block ending `_Avoid_: today marker, cursor`) and before `## Symbols`, insert:
|
||||
|
||||
```markdown
|
||||
|
||||
**Library**:
|
||||
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
|
||||
_Avoid_: workspace, project, file list
|
||||
@@ -829,6 +842,7 @@ git commit -m "docs: record the Library term and mark the plans library as built
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage**
|
||||
|
||||
- Multiple named plans in localStorage → Task 3 (`Library` model).
|
||||
- Header dropdown switcher → Task 2 (`PlanSwitcher`) + Task 4 (wiring).
|
||||
- New plan from sample → Task 3 (`newPlan`) + Task 4.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date:** 2026-06-17
|
||||
**Status:** Approved (brainstorming) → ready for implementation plan
|
||||
**Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — *"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."*
|
||||
**Realizes:** [ADR-0002](../../adr/0002-local-first-no-backend.md) — _"The live store is localStorage (an in-app library of named Macroplans); the `.toml` file is the portable source of truth, moved in and out via Import/Export."_
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -13,6 +13,7 @@ This adds the library: keep several named Macroplans, switch between them, creat
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- Multiple named Macroplans persisted in localStorage.
|
||||
- A header dropdown to switch the active plan.
|
||||
- Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library).
|
||||
@@ -21,8 +22,9 @@ This adds the library: keep several named Macroplans, switch between them, creat
|
||||
- Migration from the legacy single-key store.
|
||||
|
||||
**Out (YAGNI for v1):**
|
||||
- Duplicate / clone a plan (fork via *New* + paste).
|
||||
- File-open `.toml` *import* — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy.
|
||||
|
||||
- Duplicate / clone a plan (fork via _New_ + paste).
|
||||
- File-open `.toml` _import_ — pasting a `.toml` file's contents into a new plan's editor already covers loading. Re-evaluate if it proves clumsy.
|
||||
- Reordering, folders, search, tags.
|
||||
- Any backend or multi-device sync (explicitly rejected by ADR-0002).
|
||||
|
||||
@@ -46,11 +48,11 @@ macroplan:library → {
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. |
|
||||
| `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. |
|
||||
| `source` | The TOML string the editor binds to. |
|
||||
| Field | Meaning |
|
||||
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Stable identity from `crypto.randomUUID()`. The switcher and `activeId` key off this, **never** the name — so a plan survives title edits and broken TOML. |
|
||||
| `name` | The **cached last-good title** for that plan. Lets the switcher label every plan without parsing all of their sources on every render — only the active plan is parsed live. |
|
||||
| `source` | The TOML string the editor binds to. |
|
||||
|
||||
**Why a cached name and not a live-derived one:** only the active plan's source is parsed continuously (for the live render). Inactive plans show their last-good title from `name`. The active plan's `name` is refreshed whenever its source parses to a title; a transient parse error leaves the last-good `name` (and the last-good render) in place — same forgiving behavior as today's F3.
|
||||
|
||||
@@ -76,16 +78,16 @@ Holds `plans` and `activeId`; persists the whole library to `macroplan:library`
|
||||
|
||||
Exposes:
|
||||
|
||||
| Member | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `source` | writable `Ref<string>` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. |
|
||||
| `plan` | `ComputedRef<Plan \| null>` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). |
|
||||
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
|
||||
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
|
||||
| `activeId` | `Ref<string>` | Current selection. |
|
||||
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. |
|
||||
| `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). |
|
||||
| `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
|
||||
| Member | Type | Notes |
|
||||
| ---------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `source` | writable `Ref<string>` | Reads/writes the **active** plan's `source`. The editor's `v-model` binds here unchanged. Writing it refreshes the active plan's cached `name` when the new source parses to a title. |
|
||||
| `plan` | `ComputedRef<Plan \| null>` | Active plan's derived model, falling back to its `lastGood` (unchanged behavior). |
|
||||
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
|
||||
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
|
||||
| `activeId` | `Ref<string>` | Current selection. |
|
||||
| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. |
|
||||
| `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). |
|
||||
| `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. |
|
||||
|
||||
`resetToSample` is **removed** — `newPlan()` (a blank page) replaces it.
|
||||
|
||||
@@ -133,6 +135,7 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
|
||||
## Testing (Vitest, matching the existing suite)
|
||||
|
||||
**`useMacroplan` / library model:**
|
||||
|
||||
- No storage → library seeds one sample plan, active.
|
||||
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
|
||||
- Corrupt `macroplan:library` → seeds fresh sample (no throw).
|
||||
@@ -143,15 +146,17 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl
|
||||
- `selectPlan` swaps `source`/`plan`/`error` and resets `lastGood` (broken target shows its own error, not the prior render).
|
||||
|
||||
**Filename util:**
|
||||
|
||||
- `sourceFilename(title)` slug mirrors the existing `exportFilename` test (e.g. `"Q3 Delivery"` → `macroplan-q3-delivery.toml`); empty/garbage title → `macroplan-plan.toml`.
|
||||
|
||||
**Component (`@vue/test-utils`):**
|
||||
|
||||
- `PlanSwitcher` renders the plan names, marks the active one, and emits `select` / `new` / `delete` / `download` on the right interactions.
|
||||
|
||||
## Docs to update (in the implementation plan)
|
||||
|
||||
- **`CONTEXT.md`** — add the **Library** glossary entry above.
|
||||
- **`README.md`** — flip the *"Not yet built: a library …"* note now that it exists; the *How it works* localStorage bullet can mention named plans + `.toml` export.
|
||||
- **`README.md`** — flip the _"Not yet built: a library …"_ note now that it exists; the _How it works_ localStorage bullet can mention named plans + `.toml` export.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "macroplan",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { mount } from "@vue/test-utils"
|
||||
import { nextTick } from "vue"
|
||||
|
||||
// downloadSource performs a real DOM/Blob download; mock it so we can assert
|
||||
// the wiring without touching the filesystem. sourceFilename stays real.
|
||||
vi.mock('./composables/useSourceExport', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./composables/useSourceExport')>()
|
||||
vi.mock("./composables/useSourceExport", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./composables/useSourceExport")>()
|
||||
return { ...actual, downloadSource: vi.fn() }
|
||||
})
|
||||
|
||||
import App from './App.vue'
|
||||
import { downloadSource } from './composables/useSourceExport'
|
||||
import { SAMPLE_PLAN } from './data/sample'
|
||||
import App from "./App.vue"
|
||||
import { downloadSource } from "./composables/useSourceExport"
|
||||
import { SAMPLE_PLAN } from "./data/sample"
|
||||
|
||||
// The editor lazy-loads Shiki and the grid is heavy; neither is under test here.
|
||||
const stubs = { PlanEditor: true, MacroplanGrid: true }
|
||||
@@ -23,69 +23,67 @@ function mountApp() {
|
||||
|
||||
// dropdown links = one <a> per plan, then "New plan" and "Download .toml".
|
||||
function dropdownLinks(w: ReturnType<typeof mountApp>) {
|
||||
return w.findAll('.dropdown-content li a')
|
||||
return w.findAll(".dropdown-content li a")
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
describe('App — plan library wiring', () => {
|
||||
it('shows the active plan name in the header switcher', () => {
|
||||
expect(mountApp().text()).toContain('Q3 — Checkout revamp')
|
||||
describe("App — plan library wiring", () => {
|
||||
it("shows the active plan name in the header switcher", () => {
|
||||
expect(mountApp().text()).toContain("Q3 — Checkout revamp")
|
||||
})
|
||||
|
||||
it('creates a new plan from the dropdown', async () => {
|
||||
it("creates a new plan from the dropdown", async () => {
|
||||
const w = mountApp()
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan + New + Download
|
||||
await dropdownLinks(w)[1].trigger('click') // New plan
|
||||
await dropdownLinks(w)[1].trigger("click") // New plan
|
||||
await nextTick()
|
||||
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download
|
||||
})
|
||||
|
||||
it('opens the confirm modal and deletes the active plan on confirm', async () => {
|
||||
it("opens the confirm modal and deletes the active plan on confirm", async () => {
|
||||
const w = mountApp()
|
||||
await dropdownLinks(w)[1].trigger('click') // New → 2 plans
|
||||
await dropdownLinks(w)[1].trigger("click") // New → 2 plans
|
||||
await nextTick()
|
||||
expect(dropdownLinks(w)).toHaveLength(4)
|
||||
|
||||
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||
expect(w.find('.modal-open').exists()).toBe(true)
|
||||
await w.find('button[title="Delete this plan"]').trigger("click")
|
||||
expect(w.find(".modal-open").exists()).toBe(true)
|
||||
|
||||
await w.find('.modal-action .btn-error').trigger('click') // Delete
|
||||
await w.find(".modal-action .btn-error").trigger("click") // Delete
|
||||
await nextTick()
|
||||
expect(w.find('.modal-open').exists()).toBe(false)
|
||||
expect(w.find(".modal-open").exists()).toBe(false)
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // back to 1 plan
|
||||
})
|
||||
|
||||
it('cancel dismisses the modal without deleting', async () => {
|
||||
it("cancel dismisses the modal without deleting", async () => {
|
||||
const w = mountApp()
|
||||
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||
expect(w.find('.modal-open').exists()).toBe(true)
|
||||
await w.find('button[title="Delete this plan"]').trigger("click")
|
||||
expect(w.find(".modal-open").exists()).toBe(true)
|
||||
|
||||
const cancel = w
|
||||
.findAll('.modal-action .btn')
|
||||
.find((b) => !b.classes().includes('btn-error'))!
|
||||
await cancel.trigger('click')
|
||||
expect(w.find('.modal-open').exists()).toBe(false)
|
||||
const cancel = w.findAll(".modal-action .btn").find((b) => !b.classes().includes("btn-error"))!
|
||||
await cancel.trigger("click")
|
||||
expect(w.find(".modal-open").exists()).toBe(false)
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched
|
||||
})
|
||||
|
||||
it('re-seeds a fresh sample when the last plan is deleted (never empty)', async () => {
|
||||
it("re-seeds a fresh sample when the last plan is deleted (never empty)", async () => {
|
||||
const w = mountApp()
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan
|
||||
|
||||
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||
await w.find('.modal-action .btn-error').trigger('click') // Delete the only plan
|
||||
await w.find('button[title="Delete this plan"]').trigger("click")
|
||||
await w.find(".modal-action .btn-error").trigger("click") // Delete the only plan
|
||||
await nextTick()
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded
|
||||
expect(w.text()).toContain('Q3 — Checkout revamp')
|
||||
expect(w.text()).toContain("Q3 — Checkout revamp")
|
||||
})
|
||||
|
||||
it('wires the dropdown .toml download to downloadSource with the active source and a .toml name', async () => {
|
||||
it("wires the dropdown .toml download to downloadSource with the active source and a .toml name", async () => {
|
||||
const w = mountApp()
|
||||
await dropdownLinks(w)[2].trigger('click') // Download .toml (1 plan → index 2)
|
||||
await dropdownLinks(w)[2].trigger("click") // Download .toml (1 plan → index 2)
|
||||
expect(downloadSource).toHaveBeenCalledOnce()
|
||||
const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(src).toBe(SAMPLE_PLAN)
|
||||
expect(filename).toBe('macroplan-q3-checkout-revamp.toml')
|
||||
expect(filename).toBe("macroplan-q3-checkout-revamp.toml")
|
||||
})
|
||||
})
|
||||
|
||||
16
src/App.vue
16
src/App.vue
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMacroplan } from './composables/useMacroplan'
|
||||
import { usePngExport, exportFilename } from './composables/usePngExport'
|
||||
import { sourceFilename, downloadSource } from './composables/useSourceExport'
|
||||
import PlanEditor from './components/PlanEditor.vue'
|
||||
import MacroplanGrid from './components/MacroplanGrid.vue'
|
||||
import PlanSwitcher from './components/PlanSwitcher.vue'
|
||||
import { ref, computed } from "vue"
|
||||
import { useMacroplan } from "./composables/useMacroplan"
|
||||
import { usePngExport, exportFilename } from "./composables/usePngExport"
|
||||
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
|
||||
import PlanEditor from "./components/PlanEditor.vue"
|
||||
import MacroplanGrid from "./components/MacroplanGrid.vue"
|
||||
import PlanSwitcher from "./components/PlanSwitcher.vue"
|
||||
|
||||
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
|
||||
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
||||
@@ -14,7 +14,7 @@ const exportRoot = ref<HTMLElement>()
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
const activeName = computed(
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
|
||||
)
|
||||
|
||||
function downloadToml() {
|
||||
|
||||
@@ -20,8 +20,15 @@ Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg
|
||||
class="size-5 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- paste paths from the Tabler SVG here -->
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import MacroplanGrid from './MacroplanGrid.vue'
|
||||
import { parseMacroplan } from '../model/parse'
|
||||
import { buildPlan } from '../model/plan'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { mount } from "@vue/test-utils"
|
||||
import MacroplanGrid from "./MacroplanGrid.vue"
|
||||
import { parseMacroplan } from "../model/parse"
|
||||
import { buildPlan } from "../model/plan"
|
||||
import { SAMPLE_PLAN } from "../data/sample"
|
||||
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), '2026-06-17')
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), "2026-06-17")
|
||||
|
||||
function mountGrid() {
|
||||
return mount(MacroplanGrid, { props: { plan } })
|
||||
}
|
||||
|
||||
describe('MacroplanGrid renders the sample plan', () => {
|
||||
it('renders one row per feature, in order', () => {
|
||||
describe("MacroplanGrid renders the sample plan", () => {
|
||||
it("renders one row per feature, in order", () => {
|
||||
const names = mountGrid()
|
||||
.findAll('.namecell')
|
||||
.findAll(".namecell")
|
||||
.map((n) => n.text())
|
||||
expect(names).toEqual(['Auth', 'Payments', 'Dashboard', 'Search', 'Notifications'])
|
||||
expect(names).toEqual(["Auth", "Payments", "Dashboard", "Search", "Notifications"])
|
||||
})
|
||||
|
||||
it('renders the right markers per feature', () => {
|
||||
const rows = mountGrid().findAll('.namecell')
|
||||
it("renders the right markers per feature", () => {
|
||||
const rows = mountGrid().findAll(".namecell")
|
||||
// each feature row is namecell + week cells + learncell; grab the row text via the grid
|
||||
const grid = mountGrid()
|
||||
const text = grid.text()
|
||||
expect(rows).toHaveLength(5)
|
||||
// on-time, late, and slip glyphs all present somewhere in the grid
|
||||
expect(text).toContain('◉') // Auth delivered on time
|
||||
expect(text).toContain('▲') // Payments delivered late
|
||||
expect(text).toContain('△') // re-estimates
|
||||
expect(text).toContain('◯') // open original estimates
|
||||
expect(text).toContain("◉") // Auth delivered on time
|
||||
expect(text).toContain("▲") // Payments delivered late
|
||||
expect(text).toContain("△") // re-estimates
|
||||
expect(text).toContain("◯") // open original estimates
|
||||
})
|
||||
|
||||
it('draws a now column and the milestone header', () => {
|
||||
it("draws a now column and the milestone header", () => {
|
||||
const w = mountGrid()
|
||||
expect(w.find('.col-now').exists()).toBe(true)
|
||||
expect(w.text()).toContain('now')
|
||||
expect(w.text()).toContain('MVP go-live')
|
||||
expect(w.find(".col-now").exists()).toBe(true)
|
||||
expect(w.text()).toContain("now")
|
||||
expect(w.text()).toContain("MVP go-live")
|
||||
})
|
||||
|
||||
it('shows a learning for a delivered feature and a status note for an in-flight one', () => {
|
||||
it("shows a learning for a delivered feature and a status note for an in-flight one", () => {
|
||||
const text = mountGrid().text()
|
||||
expect(text).toContain('Vendor lead time') // Payments learning
|
||||
expect(text).toContain('No recovery plan yet') // Dashboard status note
|
||||
expect(text).toContain("Vendor lead time") // Payments learning
|
||||
expect(text).toContain("No recovery plan yet") // Dashboard status note
|
||||
})
|
||||
|
||||
it('labels week columns', () => {
|
||||
expect(mountGrid().text()).toContain('Jun 15')
|
||||
it("labels week columns", () => {
|
||||
expect(mountGrid().text()).toContain("Jun 15")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Plan, FeatureRow, MarkerKind } from '../model/types'
|
||||
import { weekLabel, type WeekId } from '../model/week'
|
||||
import { computed } from "vue"
|
||||
import type { Plan, FeatureRow, MarkerKind } from "../model/types"
|
||||
import { weekLabel, type WeekId } from "../model/week"
|
||||
|
||||
const props = defineProps<{ plan: Plan }>()
|
||||
|
||||
type Tone = 'success' | 'warning' | 'error' | 'neutral'
|
||||
type Tone = "success" | "warning" | "error" | "neutral"
|
||||
|
||||
const GLYPH: Record<MarkerKind, string> = {
|
||||
original: '◯',
|
||||
reestimate: '△',
|
||||
'delivered-on-time': '◉',
|
||||
'delivered-late': '▲',
|
||||
original: "◯",
|
||||
reestimate: "△",
|
||||
"delivered-on-time": "◉",
|
||||
"delivered-late": "▲",
|
||||
}
|
||||
const MARKER_CLASS: Record<MarkerKind, string> = {
|
||||
original: 'text-base-content/50',
|
||||
reestimate: 'text-warning',
|
||||
'delivered-on-time': 'text-success',
|
||||
'delivered-late': 'text-error',
|
||||
original: "text-base-content/50",
|
||||
reestimate: "text-warning",
|
||||
"delivered-on-time": "text-success",
|
||||
"delivered-late": "text-error",
|
||||
}
|
||||
// When two markers land on the same week, the higher rank owns the cell.
|
||||
const RANK: Record<MarkerKind, number> = {
|
||||
'delivered-late': 3,
|
||||
'delivered-on-time': 3,
|
||||
"delivered-late": 3,
|
||||
"delivered-on-time": 3,
|
||||
reestimate: 2,
|
||||
original: 1,
|
||||
}
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
error: 'text-error',
|
||||
neutral: 'text-primary',
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
error: "text-error",
|
||||
neutral: "text-primary",
|
||||
}
|
||||
const TONE_BORDER: Record<Tone, string> = {
|
||||
success: 'border-success',
|
||||
warning: 'border-warning',
|
||||
error: 'border-error',
|
||||
neutral: 'border-primary',
|
||||
success: "border-success",
|
||||
warning: "border-warning",
|
||||
error: "border-error",
|
||||
neutral: "border-primary",
|
||||
}
|
||||
const TONE_DOT: Record<Tone, string> = {
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-error',
|
||||
neutral: 'bg-base-300',
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
error: "bg-error",
|
||||
neutral: "bg-base-300",
|
||||
}
|
||||
|
||||
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
||||
@@ -57,15 +57,15 @@ const gridStyle = computed(() => ({
|
||||
}))
|
||||
|
||||
function tone(row: FeatureRow): Tone {
|
||||
if (row.delivered) return row.onTime ? 'success' : 'error'
|
||||
if (row.status === 'on-track') return 'success'
|
||||
if (row.status === 'at-risk') return 'warning'
|
||||
if (row.status === 'off-track') return 'error'
|
||||
return 'neutral'
|
||||
if (row.delivered) return row.onTime ? "success" : "error"
|
||||
if (row.status === "on-track") return "success"
|
||||
if (row.status === "at-risk") return "warning"
|
||||
if (row.status === "off-track") return "error"
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
function statusWord(row: FeatureRow): string {
|
||||
return row.status ? row.status.replace('-', ' ') : 'in flight'
|
||||
return row.status ? row.status.replace("-", " ") : "in flight"
|
||||
}
|
||||
|
||||
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
@@ -79,7 +79,7 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
interface Cell {
|
||||
// how far the bar line runs within this cell: none, center→right,
|
||||
// left→center, or full width
|
||||
line: 'none' | 'right' | 'left' | 'full'
|
||||
line: "none" | "right" | "left" | "full"
|
||||
isStart: boolean
|
||||
glyph: string
|
||||
glyphCls: string
|
||||
@@ -91,17 +91,17 @@ const matrix = computed<Cell[][]>(() =>
|
||||
weeks.value.map((w) => {
|
||||
const m = markerAt(row, w)
|
||||
const inBar = w >= row.startWeek && w <= row.barEndWeek
|
||||
let line: Cell['line'] = 'none'
|
||||
let line: Cell["line"] = "none"
|
||||
if (inBar) {
|
||||
const isStart = w === row.startWeek
|
||||
const isEnd = w === row.barEndWeek
|
||||
line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full'
|
||||
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
|
||||
}
|
||||
return {
|
||||
line,
|
||||
isStart: inBar && w === row.startWeek,
|
||||
glyph: m ? GLYPH[m] : '',
|
||||
glyphCls: m ? MARKER_CLASS[m] : '',
|
||||
glyph: m ? GLYPH[m] : "",
|
||||
glyphCls: m ? MARKER_CLASS[m] : "",
|
||||
}
|
||||
}),
|
||||
),
|
||||
@@ -119,9 +119,9 @@ const cols = computed(() =>
|
||||
|
||||
function colClass(i: number): string {
|
||||
const c = cols.value[i]
|
||||
if (c.isNow) return 'col-now'
|
||||
if (c.isMilestone) return 'col-ms'
|
||||
return ''
|
||||
if (c.isNow) return "col-now"
|
||||
if (c.isMilestone) return "col-ms"
|
||||
return ""
|
||||
}
|
||||
|
||||
// Milestone label flags above the axis, greedily stacked onto extra rows so
|
||||
@@ -134,13 +134,13 @@ const milestoneFlags = computed(() => {
|
||||
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
|
||||
return items.map((x) => {
|
||||
const startX = NAME_W + (x.col - 2) * WK
|
||||
const text = `◆ ${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : '')
|
||||
const text = `◆ ${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
|
||||
const width = text.length * BAND_CHAR + 14
|
||||
let row = 0
|
||||
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
|
||||
rowEnd[row] = startX + width
|
||||
const title = x.m.unmet.length
|
||||
? `${x.m.name} — unmet: ${x.m.unmet.join(', ')}`
|
||||
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
|
||||
: `${x.m.name} — all required features met`
|
||||
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
|
||||
})
|
||||
@@ -197,7 +197,9 @@ const bandStyle = computed(() => ({
|
||||
<div class="learncell">
|
||||
<template v-if="row.delivered">
|
||||
<span v-if="row.learning" class="note">{{ row.learning }}</span>
|
||||
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span>
|
||||
<span v-else class="muted">{{
|
||||
row.onTime ? "delivered on time" : "delivered late"
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import { getCompletions, type CompletionContext } from './completion'
|
||||
import { ref, computed, onMounted, nextTick } from "vue"
|
||||
import type { HighlighterCore } from "shiki/core"
|
||||
import { getCompletions, type CompletionContext } from "./completion"
|
||||
|
||||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement>()
|
||||
const backdrop = ref<HTMLElement>()
|
||||
@@ -25,10 +25,10 @@ onMounted(async () => {
|
||||
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
|
||||
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
|
||||
const [core, engine, toml, theme] = await Promise.all([
|
||||
import('shiki/core'),
|
||||
import('shiki/engine/javascript'),
|
||||
import('shiki/langs/toml.mjs'),
|
||||
import('shiki/themes/github-light.mjs'),
|
||||
import("shiki/core"),
|
||||
import("shiki/engine/javascript"),
|
||||
import("shiki/langs/toml.mjs"),
|
||||
import("shiki/themes/github-light.mjs"),
|
||||
])
|
||||
highlighter.value = await core.createHighlighterCore({
|
||||
themes: [theme.default],
|
||||
@@ -41,11 +41,11 @@ function measure() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const cs = getComputedStyle(el)
|
||||
const ctx = document.createElement('canvas').getContext('2d')!
|
||||
const ctx = document.createElement("canvas").getContext("2d")!
|
||||
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
|
||||
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
|
||||
metrics = {
|
||||
charWidth: ctx.measureText('0000000000').width / 10,
|
||||
charWidth: ctx.measureText("0000000000").width / 10,
|
||||
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
|
||||
padLeft: parseFloat(cs.paddingLeft),
|
||||
padTop: parseFloat(cs.paddingTop),
|
||||
@@ -66,10 +66,10 @@ function position() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const before = el.value.slice(0, el.selectionStart)
|
||||
const lineIndex = before.split('\n').length - 1
|
||||
const line = before.slice(before.lastIndexOf('\n') + 1)
|
||||
const lineIndex = before.split("\n").length - 1
|
||||
const line = before.slice(before.lastIndexOf("\n") + 1)
|
||||
let col = 0
|
||||
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1
|
||||
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
|
||||
popup.value = {
|
||||
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
|
||||
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
|
||||
@@ -83,7 +83,7 @@ function accept(i: number) {
|
||||
const item = ctx.items[i]
|
||||
const caret = ctx.from + item.insert.length
|
||||
completion.value = null
|
||||
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
|
||||
emit("update:modelValue", el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
|
||||
nextTick(() => {
|
||||
el.setSelectionRange(caret, caret)
|
||||
el.focus()
|
||||
@@ -92,7 +92,7 @@ function accept(i: number) {
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') justEscaped = false
|
||||
if (e.key !== "Escape") justEscaped = false
|
||||
const ctx = completion.value
|
||||
if (!ctx) return
|
||||
const move = (delta: number) => {
|
||||
@@ -100,32 +100,32 @@ function onKeydown(e: KeyboardEvent) {
|
||||
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
||||
}
|
||||
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
||||
if (e.ctrlKey && (e.key === 'n' || e.key === 'p')) {
|
||||
move(e.key === 'n' ? 1 : -1)
|
||||
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
|
||||
move(e.key === "n" ? 1 : -1)
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
move(1)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
move(-1)
|
||||
break
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
case "Enter":
|
||||
case "Tab":
|
||||
e.preventDefault()
|
||||
accept(selected.value)
|
||||
break
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
completion.value = null
|
||||
justEscaped = true
|
||||
break
|
||||
// Caret jumps would leave the popup stranded at a stale spot — close it.
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'Home':
|
||||
case 'End':
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
case "Home":
|
||||
case "End":
|
||||
completion.value = null
|
||||
break
|
||||
}
|
||||
@@ -133,18 +133,18 @@ function onKeydown(e: KeyboardEvent) {
|
||||
|
||||
const highlighted = computed(() => {
|
||||
// a trailing newline needs a trailing char so the last backdrop line keeps height
|
||||
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
|
||||
const code = props.modelValue.endsWith("\n") ? props.modelValue + " " : props.modelValue
|
||||
const hl = highlighter.value
|
||||
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
|
||||
return hl.codeToHtml(code, { lang: 'toml', theme: 'github-light' })
|
||||
return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
|
||||
})
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]!)
|
||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
|
||||
emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
|
||||
refresh()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlanSwitcher from './PlanSwitcher.vue'
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { mount } from "@vue/test-utils"
|
||||
import PlanSwitcher from "./PlanSwitcher.vue"
|
||||
|
||||
const plans = [
|
||||
{ id: 'a', name: 'Alpha' },
|
||||
{ id: 'b', name: 'Bravo' },
|
||||
{ id: "a", name: "Alpha" },
|
||||
{ id: "b", name: "Bravo" },
|
||||
]
|
||||
|
||||
describe('PlanSwitcher', () => {
|
||||
it('lists plan names and marks the active one', () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'b' } })
|
||||
expect(w.text()).toContain('Alpha')
|
||||
expect(w.text()).toContain('Bravo')
|
||||
expect(w.find('a.active').text()).toContain('Bravo')
|
||||
describe("PlanSwitcher", () => {
|
||||
it("lists plan names and marks the active one", () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
|
||||
expect(w.text()).toContain("Alpha")
|
||||
expect(w.text()).toContain("Bravo")
|
||||
expect(w.find("a.active").text()).toContain("Bravo")
|
||||
})
|
||||
|
||||
it('emits select with the clicked plan id', async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
|
||||
await w.findAll('li a')[1].trigger('click') // Bravo
|
||||
expect(w.emitted('select')?.[0]).toEqual(['b'])
|
||||
it("emits select with the clicked plan id", async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
|
||||
await w.findAll("li a")[1].trigger("click") // Bravo
|
||||
expect(w.emitted("select")?.[0]).toEqual(["b"])
|
||||
})
|
||||
|
||||
it('emits new and download (with the active id) from the trailing actions', async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
|
||||
const actions = w.findAll('li a')
|
||||
await actions[actions.length - 2].trigger('click') // New plan
|
||||
await actions[actions.length - 1].trigger('click') // Download .toml
|
||||
expect(w.emitted('new')).toBeTruthy()
|
||||
expect(w.emitted('download')?.[0]).toEqual(['a'])
|
||||
it("emits new and download (with the active id) from the trailing actions", async () => {
|
||||
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
|
||||
const actions = w.findAll("li a")
|
||||
await actions[actions.length - 2].trigger("click") // New plan
|
||||
await actions[actions.length - 1].trigger("click") // Download .toml
|
||||
expect(w.emitted("new")).toBeTruthy()
|
||||
expect(w.emitted("download")?.[0]).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
plans: { id: string; name: string }[]
|
||||
@@ -13,7 +13,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const activeName = computed(
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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]]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,41 +21,41 @@ export interface CompletionContext {
|
||||
items: Completion[]
|
||||
}
|
||||
|
||||
type Block = 'plan' | 'feature' | 'milestone'
|
||||
type Block = "plan" | "feature" | "milestone"
|
||||
|
||||
const PLAN_KEYS: Completion[] = [
|
||||
{ label: 'title', insert: 'title = ', detail: 'plan title' },
|
||||
{ label: 'start', insert: 'start = ', detail: 'left edge — date, optional' },
|
||||
{ label: 'end', insert: 'end = ', detail: 'right edge — date, optional' },
|
||||
{ label: "title", insert: "title = ", detail: "plan title" },
|
||||
{ label: "start", insert: "start = ", detail: "left edge — date, optional" },
|
||||
{ label: "end", insert: "end = ", detail: "right edge — date, optional" },
|
||||
]
|
||||
|
||||
const FEATURE_KEYS: Completion[] = [
|
||||
{ label: 'name', insert: 'name = ', detail: 'required' },
|
||||
{ label: 'start', insert: 'start = ', detail: 'date, required' },
|
||||
{ label: 'original', insert: 'original = ', detail: 'Original Estimate — date, required' },
|
||||
{ label: 'reestimates', insert: 'reestimates = ', detail: 'list of dates' },
|
||||
{ label: 'delivered', insert: 'delivered = ', detail: 'date' },
|
||||
{ label: 'status', insert: 'status = ', detail: 'on-track | at-risk | off-track' },
|
||||
{ label: 'learning', insert: 'learning = ', detail: 'string' },
|
||||
{ label: 'note', insert: 'note = ', detail: 'string' },
|
||||
{ label: "name", insert: "name = ", detail: "required" },
|
||||
{ label: "start", insert: "start = ", detail: "date, required" },
|
||||
{ label: "original", insert: "original = ", detail: "Original Estimate — date, required" },
|
||||
{ label: "reestimates", insert: "reestimates = ", detail: "list of dates" },
|
||||
{ label: "delivered", insert: "delivered = ", detail: "date" },
|
||||
{ label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" },
|
||||
{ label: "learning", insert: "learning = ", detail: "string" },
|
||||
{ label: "note", insert: "note = ", detail: "string" },
|
||||
]
|
||||
|
||||
const MILESTONE_KEYS: Completion[] = [
|
||||
{ label: 'name', insert: 'name = ', detail: 'required' },
|
||||
{ label: 'week', insert: 'week = ', detail: 'date, required' },
|
||||
{ label: 'requires', insert: 'requires = ', detail: 'list of feature names' },
|
||||
{ label: "name", insert: "name = ", detail: "required" },
|
||||
{ label: "week", insert: "week = ", detail: "date, required" },
|
||||
{ label: "requires", insert: "requires = ", detail: "list of feature names" },
|
||||
]
|
||||
|
||||
const HEADERS: Completion[] = [
|
||||
{ label: '[[feature]]', insert: '[[feature]]', detail: 'new feature', filter: 'feature' },
|
||||
{ label: '[[milestone]]', insert: '[[milestone]]', detail: 'new milestone', filter: 'milestone' },
|
||||
{ label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" },
|
||||
{ label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" },
|
||||
]
|
||||
|
||||
const STATUSES = ['on-track', 'at-risk', 'off-track'] // keep in sync with parse.ts
|
||||
const STATUSES = ["on-track", "at-risk", "off-track"] // keep in sync with parse.ts
|
||||
|
||||
/** What to suggest at `caret` in `source`, or null when nothing applies. */
|
||||
export function getCompletions(source: string, caret: number): CompletionContext | null {
|
||||
const lineStart = source.lastIndexOf('\n', caret - 1) + 1
|
||||
const lineStart = source.lastIndexOf("\n", caret - 1) + 1
|
||||
const linePrefix = source.slice(lineStart, caret)
|
||||
|
||||
// ── value: status = "<here>" ──────────────────────────────────────────────
|
||||
@@ -71,7 +71,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
|
||||
const block = currentBlock(source, lineStart)
|
||||
|
||||
// ── value: requires = [ "<here>" … ] (milestones only) ───────────────────
|
||||
if (block === 'milestone' && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
|
||||
if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
|
||||
const token = /("?)([^",[\]]*)$/.exec(linePrefix)!
|
||||
const items = filter(
|
||||
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
|
||||
@@ -88,7 +88,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
|
||||
return result(lineStart + indent.length, caret, filter(HEADERS, word))
|
||||
}
|
||||
const keys =
|
||||
block === 'feature' ? FEATURE_KEYS : block === 'milestone' ? MILESTONE_KEYS : PLAN_KEYS
|
||||
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
||||
const taken = presentKeys(source, lineStart, block)
|
||||
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
|
||||
return result(caret - word.length, caret, items)
|
||||
@@ -111,7 +111,7 @@ const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm
|
||||
|
||||
/** The block owning the line at `lineStart` — 'plan' before the first header. */
|
||||
function currentBlock(source: string, lineStart: number): Block {
|
||||
let block: Block = 'plan'
|
||||
let block: Block = "plan"
|
||||
HEADER_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
|
||||
@@ -129,7 +129,7 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
|
||||
|
||||
let start = 0
|
||||
let end = source.length
|
||||
if (block === 'plan') {
|
||||
if (block === "plan") {
|
||||
end = headers[0] ?? source.length
|
||||
} else {
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
@@ -151,11 +151,11 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
|
||||
function featureNames(source: string): string[] {
|
||||
const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm
|
||||
const names = new Set<string>()
|
||||
let block: Block = 'plan'
|
||||
let block: Block = "plan"
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
if (m[1]) block = m[1] as Block
|
||||
else if (m[2] && block === 'feature') names.add(m[2])
|
||||
else if (m[2] && block === "feature") names.add(m[2])
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref } from "vue"
|
||||
|
||||
type Toast = { kind: 'ok' | 'err'; text: string } | null
|
||||
type Toast = { kind: "ok" | "err"; text: string } | null
|
||||
|
||||
/** Lowercase, dash-collapsed slug of a plan title — no extension, no fallback. */
|
||||
export function slugify(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
/** Slugified, stable download name derived from the plan title. */
|
||||
export function exportFilename(title: string): string {
|
||||
return `macroplan-${slugify(title) || 'plan'}.png`
|
||||
return `macroplan-${slugify(title) || "plan"}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,26 +28,26 @@ export function usePngExport() {
|
||||
const toast = ref<Toast>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function flash(kind: 'ok' | 'err', text: string) {
|
||||
function flash(kind: "ok" | "err", text: string) {
|
||||
toast.value = { kind, text }
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => (toast.value = null), 3000)
|
||||
}
|
||||
|
||||
async function render(el: HTMLElement): Promise<Blob> {
|
||||
const { toBlob } = await import('html-to-image')
|
||||
const { toBlob } = await import("html-to-image")
|
||||
// `exporting` expands the wrapper to max-content and un-clips the grid so the
|
||||
// whole timeline is captured even when it scrolls horizontally on screen.
|
||||
el.classList.add('exporting')
|
||||
el.classList.add("exporting")
|
||||
// reading layout flushes the class's style changes before we measure
|
||||
const width = el.scrollWidth
|
||||
const height = el.scrollHeight
|
||||
try {
|
||||
const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true })
|
||||
if (!blob) throw new Error('renderer returned no image')
|
||||
if (!blob) throw new Error("renderer returned no image")
|
||||
return blob
|
||||
} finally {
|
||||
el.classList.remove('exporting')
|
||||
el.classList.remove("exporting")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,29 +57,29 @@ export function usePngExport() {
|
||||
try {
|
||||
// ClipboardItem accepts a Promise<Blob>, so the async render stays inside
|
||||
// the user-gesture window — Safari rejects a write started after an await.
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })])
|
||||
flash('ok', 'PNG copied to clipboard')
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": render(el) })])
|
||||
flash("ok", "PNG copied to clipboard")
|
||||
} catch {
|
||||
flash('err', '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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseMacroplan, PlanParseError } from './parse'
|
||||
import { buildPlan } from './plan'
|
||||
import { mondayOf, weekRange } from './week'
|
||||
import type { FeatureRow } from './types'
|
||||
import { SAMPLE_PLAN } from '../data/sample'
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseMacroplan, PlanParseError } from "./parse"
|
||||
import { buildPlan } from "./plan"
|
||||
import { mondayOf, weekRange } from "./week"
|
||||
import type { FeatureRow } from "./types"
|
||||
import { SAMPLE_PLAN } from "../data/sample"
|
||||
|
||||
const TODAY = '2026-06-17' // a Wednesday → week of Mon 2026-06-15
|
||||
const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
|
||||
|
||||
function rowOf(source: string, name: string): FeatureRow {
|
||||
const plan = buildPlan(parseMacroplan(source), TODAY)
|
||||
@@ -14,168 +14,168 @@ function rowOf(source: string, name: string): FeatureRow {
|
||||
return row
|
||||
}
|
||||
|
||||
describe('week math', () => {
|
||||
it('snaps any day to its Monday', () => {
|
||||
expect(mondayOf('2026-06-17')).toBe('2026-06-15') // Wed → Mon
|
||||
expect(mondayOf('2026-06-15')).toBe('2026-06-15') // Mon → Mon
|
||||
expect(mondayOf('2026-06-21')).toBe('2026-06-15') // Sun → that week's Mon
|
||||
expect(mondayOf('2026-06-01')).toBe('2026-06-01') // Mon
|
||||
describe("week math", () => {
|
||||
it("snaps any day to its Monday", () => {
|
||||
expect(mondayOf("2026-06-17")).toBe("2026-06-15") // Wed → Mon
|
||||
expect(mondayOf("2026-06-15")).toBe("2026-06-15") // Mon → Mon
|
||||
expect(mondayOf("2026-06-21")).toBe("2026-06-15") // Sun → that week's Mon
|
||||
expect(mondayOf("2026-06-01")).toBe("2026-06-01") // Mon
|
||||
})
|
||||
|
||||
it('builds an inclusive contiguous Monday range', () => {
|
||||
expect(weekRange('2026-06-01', '2026-06-22')).toEqual([
|
||||
'2026-06-01',
|
||||
'2026-06-08',
|
||||
'2026-06-15',
|
||||
'2026-06-22',
|
||||
it("builds an inclusive contiguous Monday range", () => {
|
||||
expect(weekRange("2026-06-01", "2026-06-22")).toEqual([
|
||||
"2026-06-01",
|
||||
"2026-06-08",
|
||||
"2026-06-15",
|
||||
"2026-06-22",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('F2 — on-time / late classification (ADR-0001)', () => {
|
||||
describe("F2 — on-time / late classification (ADR-0001)", () => {
|
||||
const base = (extra: string) =>
|
||||
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n`
|
||||
|
||||
it('delivered on the Original Estimate week → ◉, no ◯, onTime true', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-15'), 'X')
|
||||
it("delivered on the Original Estimate week → ◉, no ◯, onTime true", () => {
|
||||
const r = rowOf(base("delivered = 2026-06-15"), "X")
|
||||
expect(r.onTime).toBe(true)
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
|
||||
expect(r.markers[0].week).toBe('2026-06-15')
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
|
||||
expect(r.markers[0].week).toBe("2026-06-15")
|
||||
})
|
||||
|
||||
it('delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-08'), 'X')
|
||||
it("delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery", () => {
|
||||
const r = rowOf(base("delivered = 2026-06-08"), "X")
|
||||
expect(r.onTime).toBe(true)
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
|
||||
expect(r.markers[0].week).toBe('2026-06-08')
|
||||
expect(r.barEndWeek).toBe('2026-06-08') // no ◯ dangling in the future
|
||||
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
|
||||
expect(r.markers[0].week).toBe("2026-06-08")
|
||||
expect(r.barEndWeek).toBe("2026-06-08") // no ◯ dangling in the future
|
||||
})
|
||||
|
||||
it('delivered after the Original Estimate → ▲ late, ◯ baseline preserved', () => {
|
||||
const r = rowOf(base('delivered = 2026-06-29'), 'X')
|
||||
it("delivered after the Original Estimate → ▲ late, ◯ baseline preserved", () => {
|
||||
const r = rowOf(base("delivered = 2026-06-29"), "X")
|
||||
expect(r.onTime).toBe(false)
|
||||
const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort()
|
||||
expect(kinds).toEqual(['delivered-late@2026-06-29', 'original@2026-06-15'])
|
||||
expect(r.barEndWeek).toBe('2026-06-29')
|
||||
expect(kinds).toEqual(["delivered-late@2026-06-29", "original@2026-06-15"])
|
||||
expect(r.barEndWeek).toBe("2026-06-29")
|
||||
})
|
||||
|
||||
it('late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)', () => {
|
||||
const r = rowOf(
|
||||
base('reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20'),
|
||||
'X',
|
||||
)
|
||||
it("late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)", () => {
|
||||
const r = rowOf(base("reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20"), "X")
|
||||
expect(r.onTime).toBe(false) // 07-20 > original 06-15, regardless of the 07-13 re-estimate
|
||||
expect(r.slipCount).toBe(2)
|
||||
const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => {
|
||||
;(acc[m.kind] ??= []).push(m.week)
|
||||
return acc
|
||||
}, {})
|
||||
expect(byKind.original).toEqual(['2026-06-15'])
|
||||
expect(byKind.reestimate?.sort()).toEqual(['2026-06-29', '2026-07-13'])
|
||||
expect(byKind['delivered-late']).toEqual(['2026-07-20'])
|
||||
expect(byKind.original).toEqual(["2026-06-15"])
|
||||
expect(byKind.reestimate?.sort()).toEqual(["2026-06-29", "2026-07-13"])
|
||||
expect(byKind["delivered-late"]).toEqual(["2026-07-20"])
|
||||
})
|
||||
|
||||
it('in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate', () => {
|
||||
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), 'X')
|
||||
it("in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate", () => {
|
||||
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), "X")
|
||||
expect(r.onTime).toBeNull()
|
||||
expect(r.delivered).toBe(false)
|
||||
expect(r.status).toBe('off-track')
|
||||
expect(r.barEndWeek).toBe('2026-06-29') // furthest open estimate
|
||||
expect(r.markers.some((m) => m.kind === 'original')).toBe(true)
|
||||
expect(r.status).toBe("off-track")
|
||||
expect(r.barEndWeek).toBe("2026-06-29") // furthest open estimate
|
||||
expect(r.markers.some((m) => m.kind === "original")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bar extent relative to now', () => {
|
||||
describe("bar extent relative to now", () => {
|
||||
const feat = (extra: string) =>
|
||||
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-08\n${extra}\n`
|
||||
|
||||
it('extends an overdue undelivered bar to the now week, keeping ◯ at the estimate', () => {
|
||||
it("extends an overdue undelivered bar to the now week, keeping ◯ at the estimate", () => {
|
||||
// original 2026-06-08 is before now (week of 2026-06-15) and undelivered → overdue
|
||||
const r = rowOf(feat('status = "off-track"'), 'X')
|
||||
const r = rowOf(feat('status = "off-track"'), "X")
|
||||
expect(r.delivered).toBe(false)
|
||||
expect(r.barEndWeek).toBe('2026-06-15') // runs up to now, past the 06-08 estimate
|
||||
expect(r.markers.find((m) => m.kind === 'original')?.week).toBe('2026-06-08')
|
||||
expect(r.barEndWeek).toBe("2026-06-15") // runs up to now, past the 06-08 estimate
|
||||
expect(r.markers.find((m) => m.kind === "original")?.week).toBe("2026-06-08")
|
||||
})
|
||||
|
||||
it('does not extend a delivered bar past its delivery, even when now is later', () => {
|
||||
const r = rowOf(feat('delivered = 2026-06-08'), 'X')
|
||||
expect(r.barEndWeek).toBe('2026-06-08') // ends at delivery, not at the now week
|
||||
it("does not extend a delivered bar past its delivery, even when now is later", () => {
|
||||
const r = rowOf(feat("delivered = 2026-06-08"), "X")
|
||||
expect(r.barEndWeek).toBe("2026-06-08") // ends at delivery, not at the now week
|
||||
})
|
||||
|
||||
it('ends an on-track undelivered bar at the future estimate (no extension back to now)', () => {
|
||||
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', 'X')
|
||||
expect(r.barEndWeek).toBe('2026-07-06')
|
||||
it("ends an on-track undelivered bar at the future estimate (no extension back to now)", () => {
|
||||
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', "X")
|
||||
expect(r.barEndWeek).toBe("2026-07-06")
|
||||
})
|
||||
})
|
||||
|
||||
describe('plan derivation', () => {
|
||||
it('derives a contiguous week range and places the now line', () => {
|
||||
describe("plan derivation", () => {
|
||||
it("derives a contiguous week range and places the now line", () => {
|
||||
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
|
||||
expect(plan.weeks[0]).toBe('2026-05-25') // authored span start (lead-in before earliest Feature)
|
||||
expect(plan.weeks.at(-1)).toBe('2026-08-03') // authored span end (trailing past last marker)
|
||||
expect(plan.weeks[0]).toBe("2026-05-25") // authored span start (lead-in before earliest Feature)
|
||||
expect(plan.weeks.at(-1)).toBe("2026-08-03") // authored span end (trailing past last marker)
|
||||
// contiguous, weekly
|
||||
expect(plan.weeks).toContain('2026-06-29')
|
||||
expect(plan.nowWeek).toBe('2026-06-15')
|
||||
expect(plan.weeks).toContain("2026-06-29")
|
||||
expect(plan.nowWeek).toBe("2026-06-15")
|
||||
expect(plan.nowInRange).toBe(true)
|
||||
})
|
||||
|
||||
it('flags a 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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user