Compare commits
18 Commits
c72a95d942
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34219a6bdc | ||
|
|
fbfbbdc0be | ||
|
|
b17de475ff | ||
|
|
f7e856b082 | ||
|
|
8c14ae9c42 | ||
|
|
f535c9ede5 | ||
|
|
616a4c4301 | ||
|
|
83f04af8d7 | ||
|
|
53716a13ea | ||
|
|
d0a34ef28a | ||
|
|
e380e6d336 | ||
|
|
9ee7c05019 | ||
|
|
16b07844a8 | ||
|
|
f20935a85b | ||
|
|
96c3f489a3 | ||
|
|
449be7766e | ||
|
|
db77314a88 | ||
|
|
e9c9f04d68 |
@@ -48,6 +48,10 @@ _Avoid_: today marker, cursor
|
|||||||
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
|
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
|
||||||
_Avoid_: workspace, project, file list
|
_Avoid_: workspace, project, file list
|
||||||
|
|
||||||
|
**Format version**:
|
||||||
|
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
|
||||||
|
_Avoid_: schema version, file version, app version
|
||||||
|
|
||||||
## Symbols
|
## Symbols
|
||||||
|
|
||||||
- `┣` start of a Feature's bar
|
- `┣` start of a Feature's bar
|
||||||
|
|||||||
239
DESIGN.md
239
DESIGN.md
@@ -6,122 +6,7 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Goals — the WHATs
|
## House of Quality
|
||||||
|
|
||||||
| ID | Goal | Weight | Source |
|
|
||||||
| --- | -------------------------------------------------------------------------------------------- | :----: | -------------------------------- |
|
|
||||||
| G1 | See at a glance where every Feature stands against its Original Estimate — the honest record | 10 | brief + [CONTEXT.md](CONTEXT.md) |
|
|
||||||
| G2 | Turn estimation misses into captured Learnings for next time | 8 | brief + [CONTEXT.md](CONTEXT.md) |
|
|
||||||
| G4 | Author & update the whole plan fast during a weekly review | 8 | brief |
|
|
||||||
| G3 | Know whether external Milestones are at risk | 7 | brief + [CONTEXT.md](CONTEXT.md) |
|
|
||||||
| G5 | Hand stakeholders a shareable read-only view | 5 | brief |
|
|
||||||
|
|
||||||
## 2. Functions — the HOWs
|
|
||||||
|
|
||||||
| ID | Function | Dir | Target (now) |
|
|
||||||
| --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤3s; Feature name + week axis never lost even when the plan exceeds the viewport |
|
|
||||||
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
|
|
||||||
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
|
|
||||||
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
|
|
||||||
| F5 | Make it easy to attach an optional Learning to a delivered Feature | ↓ | one optional field; rendered in the trailing column when present, blank otherwise |
|
|
||||||
| F6 | Render a Milestone and flag its unmet required Features | → | vertical line at the correct Week; unmet required Features identifiable |
|
|
||||||
| F7 | Export the rendered plan as a shareable image (clipboard + download) | → | one click → PNG on clipboard and/or downloaded; fully client-side, no backend |
|
|
||||||
|
|
||||||
## 3. Cascade — Goals → Functions → How → Components
|
|
||||||
|
|
||||||
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
|
|
||||||
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤3s; name + axis never lost_
|
|
||||||
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
|
|
||||||
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
|
|
||||||
- **G2** Turn estimation misses into captured Learnings _W:8_
|
|
||||||
- **F5** Make it easy to attach an optional Learning to a delivered Feature _Dir↓ one optional field_
|
|
||||||
- **How**: a single optional `learning` field on a Feature block; rendered in a persistent trailing column when present
|
|
||||||
- **Component**: C3 (trailing Learning column), C1 (TOML field)
|
|
||||||
- **G4** Author & update the whole plan fast during a weekly review _W:8_
|
|
||||||
- **F4** Add / edit / remove a Feature with a single local edit _Dir↓ one block per op, no ripple_
|
|
||||||
- **How**: TOML `[[feature]]` / `[[milestone]]` blocks keyed by date literals; renderer derives Weeks & marker placement so edits never ripple. See T2.
|
|
||||||
- **Component**: **C1 TOML source + parser** (smol-toml) → Plan model
|
|
||||||
- **F3** Reflect a source edit in the view _Dir↓ ≤1s_
|
|
||||||
- **How**: in-app split editor, re-parse on every keystroke (instant); autosave to localStorage. Chosen over Vite-HMR-on-file (needs a running toolchain) and load-file-only (slow loop). See T3.
|
|
||||||
- **Component**: **C4 Split editor** (editor pane + Vue reactivity)
|
|
||||||
- **G3** Know whether external Milestones are at risk _W:7_
|
|
||||||
- **F6** Render a Milestone and flag its unmet required Features _Dir→ correct Week; unmet identifiable_
|
|
||||||
- **How**: `[[milestone]]` references Features by name; renderer draws a vertical line at the milestone Week and marks which required Features are undelivered
|
|
||||||
- **Component**: C2 (membership + unmet computation), C3 (vertical line overlay)
|
|
||||||
- **G5** Hand stakeholders a shareable read-only view _W:5_
|
|
||||||
- **F7** Export the rendered plan as a shareable image _Dir→ one click → PNG_
|
|
||||||
- **How**: client-side DOM-to-PNG (html-to-image) → clipboard + download. Chosen over hosting a URL, which has no data unless the source is also shipped (local-first tool). See T1, T4.
|
|
||||||
- **Component**: **C6 Image exporter**
|
|
||||||
- **F2** Classify each Delivery on-time/late vs the Original Estimate _Dir→ 100% correct per ADR-0001_ (serves G1)
|
|
||||||
- **How**: pure derivation in the Plan model — compare Delivery Week to the Original Estimate Week; never to a Re-estimate
|
|
||||||
- **Component**: **C2 Plan model** — derives contiguous Monday Weeks, classifies markers, computes Milestone membership
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
| ID | Component | Realises | ADR |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------- | ------------------ | -------- |
|
|
||||||
| C1 | TOML source + parser (smol-toml) → Plan model | F4, feeds F2 | ADR-0002 |
|
|
||||||
| C2 | Plan model — Week derivation, on-time/late classification, Milestone membership | F2, F6 | ADR-0001 |
|
|
||||||
| C3 | Grid renderer (Vue + CSS Grid) — bars, symbols, colors, Now line, sticky panes, hover, trailing Learning column | F1, F5, F6 | — |
|
|
||||||
| C4 | Split editor — parse-on-keystroke, localStorage autosave | F3 | ADR-0002 |
|
|
||||||
| C5 | Plan library — named Macroplans in localStorage, switch/CRUD, Import/Export .toml | scope, persistence | ADR-0002 |
|
|
||||||
| C6 | Image exporter (html-to-image) — PNG to clipboard + download | F7 | — |
|
|
||||||
|
|
||||||
## 4. House — Goals × Functions
|
|
||||||
|
|
||||||
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Σ = `Σ(weight × strength)`.
|
|
||||||
|
|
||||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|
|
||||||
| --------- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
||||||
| G1 (10) | 9 | 9 | 1 | 1 | | 1 | |
|
|
||||||
| G2 (8) | 3 | | | | 9 | | |
|
|
||||||
| G4 (8) | 1 | | 9 | 9 | 3 | 1 | |
|
|
||||||
| G3 (7) | 3 | 3 | | | | 9 | |
|
|
||||||
| G5 (5) | 3 | | | | | | 9 |
|
|
||||||
| **Σ** | 158 | 111 | 82 | 82 | 96 | 81 | 45 |
|
|
||||||
| **Rel %** | 24 | 17 | 13 | 13 | 15 | 12 | 7 |
|
|
||||||
|
|
||||||
**Top engineering priorities:** **F1 (render, 24%)** and **F2 (classify, 17%)** carry the most goal-value — together they _are_ G1, the anchor goal, so the grid renderer and the Plan-model classifier deserve the most care. **F5 (15%)** ranks third despite a single goal because it is the _sole_ driver of G2 (weight 8) — under-investing in frictionless Learning capture silently abandons the learning goal. The authoring pair **F3/F4 (13% each)** matter as a unit. **F7 (7%)** is genuinely a nice-to-have; keep it cheap. (Note: this importance lens differs from §7's risk lens, where F2 ranks first as the hard correctness gate.)
|
|
||||||
|
|
||||||
## 5. Roof — Function × Function tradeoffs
|
|
||||||
|
|
||||||
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
|
|
||||||
|
|
||||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|
|
||||||
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
||||||
| **F1** | — | ○ | × | | | | × |
|
|
||||||
| **F2** | | — | | ○ | | | |
|
|
||||||
| **F3** | | | — | ◎ | | | |
|
|
||||||
| **F4** | | | | — | | | |
|
|
||||||
| **F5** | | | | | — | | |
|
|
||||||
| **F6** | | | | | | — | |
|
|
||||||
| **F7** | | | | | | | — |
|
|
||||||
|
|
||||||
**Conflicts that actually shape the design:**
|
|
||||||
|
|
||||||
- **F1 × F7 (×).** The richer F1's _hover-only_ content, the more an exported image (F7) loses. Mitigation: keep the status _color_ always-visible; only the note is hover-only. Owned by tension in §8.
|
|
||||||
- **F1 × F3 (×).** A heavier render (sticky panes, many cells) can slow the ≤1s reflect loop. Mitigation in §7: debounce / parse only changed blocks.
|
|
||||||
- **F3 ◎ F4.** Instant reflect + local ripple-free edits reinforce strongly — together they _are_ fast authoring (G4). Invest in them as a pair.
|
|
||||||
- **F2 ○ F4.** TOML's explicit date literals feed reliable classification — structured source _helps_ correctness (this is why the earlier "terse-vs-robust" tension dissolved once we chose TOML over a DSL).
|
|
||||||
|
|
||||||
## 6. Function → Component map
|
|
||||||
|
|
||||||
Strength of each Component in realising each Function (9/3/1/blank). Component list and ADR anchors are in §3.
|
|
||||||
|
|
||||||
| | C1 | C2 | C3 | C4 | C5 | C6 |
|
|
||||||
| ------ | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
||||||
| **F1** | | 3 | 9 | | | |
|
|
||||||
| **F2** | 3 | 9 | | | | |
|
|
||||||
| **F3** | 3 | | | 9 | | |
|
|
||||||
| **F4** | 9 | | | 3 | | |
|
|
||||||
| **F5** | 3 | | 9 | | | |
|
|
||||||
| **F6** | | 9 | 3 | | | |
|
|
||||||
| **F7** | | | 3 | | | 9 |
|
|
||||||
|
|
||||||
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
|
|
||||||
|
|
||||||
### House of Quality (rendered)
|
|
||||||
|
|
||||||
```tikz
|
```tikz
|
||||||
% =====================================================================
|
% =====================================================================
|
||||||
@@ -402,6 +287,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
|||||||
\def\qfdNW{5}
|
\def\qfdNW{5}
|
||||||
\def\qfdNH{7}
|
\def\qfdNH{7}
|
||||||
\def\qfdWhatW{4.6}
|
\def\qfdWhatW{4.6}
|
||||||
|
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
|
||||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||||
\def\qfdImpTitle{Weight}
|
\def\qfdImpTitle{Weight}
|
||||||
\def\qfdProjectTitle{Macroplan}
|
\def\qfdProjectTitle{Macroplan}
|
||||||
@@ -470,7 +356,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
|||||||
|
|
||||||
% Basement: target / difficulty / abs / rel
|
% Basement: target / difficulty / abs / rel
|
||||||
\foreach \c/\tgt/\diff/\abs/\rel in {%
|
\foreach \c/\tgt/\diff/\abs/\rel in {%
|
||||||
1/{state $\leq$3s}/4/158/24,
|
1/{state $\leq$1s}/4/158/24,
|
||||||
2/{100\%}/2/111/17,
|
2/{100\%}/2/111/17,
|
||||||
3/{$\leq$1s}/2/82/13,
|
3/{$\leq$1s}/2/82/13,
|
||||||
4/{1 block}/2/82/13,
|
4/{1 block}/2/82/13,
|
||||||
@@ -488,12 +374,129 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
|||||||
|
|
||||||
Basement rows (top→bottom): **target · difficulty (1–5) · absolute weight · relative weight %**.
|
Basement rows (top→bottom): **target · difficulty (1–5) · absolute weight · relative weight %**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals — the WHATs
|
||||||
|
|
||||||
|
| ID | Goal | Weight | Source |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------- | :----: | -------------------------------- |
|
||||||
|
| G1 | See at a glance where every Feature stands against its Original Estimate — the honest record | 10 | brief + [CONTEXT.md](CONTEXT.md) |
|
||||||
|
| G2 | Turn estimation misses into captured Learnings for next time | 8 | brief + [CONTEXT.md](CONTEXT.md) |
|
||||||
|
| G4 | Author & update the whole plan fast during a weekly review | 8 | brief |
|
||||||
|
| G3 | Know whether external Milestones are at risk | 7 | brief + [CONTEXT.md](CONTEXT.md) |
|
||||||
|
| G5 | Hand stakeholders a shareable read-only view | 5 | brief |
|
||||||
|
|
||||||
|
## 2. Functions — the HOWs
|
||||||
|
|
||||||
|
| ID | Function | Dir | Target (now) |
|
||||||
|
| --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤1s; Feature name + week axis never lost even when the plan exceeds the viewport |
|
||||||
|
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
|
||||||
|
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
|
||||||
|
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
|
||||||
|
| F5 | Make it easy to attach an optional Learning to a delivered Feature | ↓ | one optional field; rendered in the trailing column when present, blank otherwise |
|
||||||
|
| F6 | Render a Milestone and flag its unmet required Features | → | vertical line at the correct Week; unmet required Features identifiable |
|
||||||
|
| F7 | Export the rendered plan as a shareable image (clipboard + download) | → | one click → PNG on clipboard and/or downloaded; fully client-side, no backend |
|
||||||
|
|
||||||
|
## 3. Cascade — Goals → Functions → How → Components
|
||||||
|
|
||||||
|
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
|
||||||
|
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤1s; name + axis never lost_
|
||||||
|
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
|
||||||
|
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
|
||||||
|
- **G2** Turn estimation misses into captured Learnings _W:8_
|
||||||
|
- **F5** Make it easy to attach an optional Learning to a delivered Feature _Dir↓ one optional field_
|
||||||
|
- **How**: a single optional `learning` field on a Feature block; rendered in a persistent trailing column when present
|
||||||
|
- **Component**: C3 (trailing Learning column), C1 (TOML field)
|
||||||
|
- **G4** Author & update the whole plan fast during a weekly review _W:8_
|
||||||
|
- **F4** Add / edit / remove a Feature with a single local edit _Dir↓ one block per op, no ripple_
|
||||||
|
- **How**: TOML `[[feature]]` / `[[milestone]]` blocks keyed by date literals; renderer derives Weeks & marker placement so edits never ripple. See T2.
|
||||||
|
- **Component**: **C1 TOML source + parser** (smol-toml) → Plan model
|
||||||
|
- **F3** Reflect a source edit in the view _Dir↓ ≤1s_
|
||||||
|
- **How**: in-app split editor, re-parse on every keystroke (instant); autosave to localStorage. Chosen over Vite-HMR-on-file (needs a running toolchain) and load-file-only (slow loop). See T3.
|
||||||
|
- **Component**: **C4 Split editor** (editor pane + Vue reactivity)
|
||||||
|
- **G3** Know whether external Milestones are at risk _W:7_
|
||||||
|
- **F6** Render a Milestone and flag its unmet required Features _Dir→ correct Week; unmet identifiable_
|
||||||
|
- **How**: `[[milestone]]` references Features by name; renderer draws a vertical line at the milestone Week and marks which required Features are undelivered
|
||||||
|
- **Component**: C2 (membership + unmet computation), C3 (vertical line overlay)
|
||||||
|
- **G5** Hand stakeholders a shareable read-only view _W:5_
|
||||||
|
- **F7** Export the rendered plan as a shareable image _Dir→ one click → PNG_
|
||||||
|
- **How**: client-side DOM-to-PNG (html-to-image) → clipboard + download. Chosen over hosting a URL, which has no data unless the source is also shipped (local-first tool). See T1, T4.
|
||||||
|
- **Component**: **C6 Image exporter**
|
||||||
|
- **F2** Classify each Delivery on-time/late vs the Original Estimate _Dir→ 100% correct per ADR-0001_ (serves G1)
|
||||||
|
- **How**: pure derivation in the Plan model — compare Delivery Week to the Original Estimate Week; never to a Re-estimate
|
||||||
|
- **Component**: **C2 Plan model** — derives contiguous Monday Weeks, classifies markers, computes Milestone membership
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| ID | Component | Realises | ADR |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------- | ------------------ | -------- |
|
||||||
|
| C1 | TOML source + parser (smol-toml) → Plan model | F4, feeds F2 | ADR-0002 |
|
||||||
|
| C2 | Plan model — Week derivation, on-time/late classification, Milestone membership | F2, F6 | ADR-0001 |
|
||||||
|
| C3 | Grid renderer (Vue + CSS Grid) — bars, symbols, colors, Now line, sticky panes, hover, trailing Learning column | F1, F5, F6 | — |
|
||||||
|
| C4 | Split editor — parse-on-keystroke, localStorage autosave | F3 | ADR-0002 |
|
||||||
|
| C5 | Plan library — named Macroplans in localStorage, switch/CRUD, Import/Export .toml | scope, persistence | ADR-0002 |
|
||||||
|
| C6 | Image exporter (html-to-image) — PNG to clipboard + download | F7 | — |
|
||||||
|
|
||||||
|
## 4. House — Goals × Functions
|
||||||
|
|
||||||
|
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Σ = `Σ(weight × strength)`.
|
||||||
|
|
||||||
|
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|
||||||
|
| --------- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| G1 (10) | 9 | 9 | 1 | 1 | | 1 | |
|
||||||
|
| G2 (8) | 3 | | | | 9 | | |
|
||||||
|
| G4 (8) | 1 | | 9 | 9 | 3 | 1 | |
|
||||||
|
| G3 (7) | 3 | 3 | | | | 9 | |
|
||||||
|
| G5 (5) | 3 | | | | | | 9 |
|
||||||
|
| **Σ** | 158 | 111 | 82 | 82 | 96 | 81 | 45 |
|
||||||
|
| **Rel %** | 24 | 17 | 13 | 13 | 15 | 12 | 7 |
|
||||||
|
|
||||||
|
**Top engineering priorities:** **F1 (render, 24%)** and **F2 (classify, 17%)** carry the most goal-value — together they _are_ G1, the anchor goal, so the grid renderer and the Plan-model classifier deserve the most care. **F5 (15%)** ranks third despite a single goal because it is the _sole_ driver of G2 (weight 8) — under-investing in frictionless Learning capture silently abandons the learning goal. The authoring pair **F3/F4 (13% each)** matter as a unit. **F7 (7%)** is genuinely a nice-to-have; keep it cheap. (Note: this importance lens differs from §7's risk lens, where F2 ranks first as the hard correctness gate.)
|
||||||
|
|
||||||
|
## 5. Roof — Function × Function tradeoffs
|
||||||
|
|
||||||
|
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
|
||||||
|
|
||||||
|
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 |
|
||||||
|
| ------ | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| **F1** | — | ○ | × | | | | × |
|
||||||
|
| **F2** | | — | | ○ | | | |
|
||||||
|
| **F3** | | | — | ◎ | | | |
|
||||||
|
| **F4** | | | | — | | | |
|
||||||
|
| **F5** | | | | | — | | |
|
||||||
|
| **F6** | | | | | | — | |
|
||||||
|
| **F7** | | | | | | | — |
|
||||||
|
|
||||||
|
**Conflicts that actually shape the design:**
|
||||||
|
|
||||||
|
- **F1 × F7 (×).** The richer F1's _hover-only_ content, the more an exported image (F7) loses. Mitigation: keep the status _color_ always-visible; only the note is hover-only. Owned by tension in §8.
|
||||||
|
- **F1 × F3 (×).** A heavier render (sticky panes, many cells) can slow the ≤1s reflect loop. Mitigation in §7: debounce / parse only changed blocks.
|
||||||
|
- **F3 ◎ F4.** Instant reflect + local ripple-free edits reinforce strongly — together they _are_ fast authoring (G4). Invest in them as a pair.
|
||||||
|
- **F2 ○ F4.** TOML's explicit date literals feed reliable classification — structured source _helps_ correctness (this is why the earlier "terse-vs-robust" tension dissolved once we chose TOML over a DSL).
|
||||||
|
|
||||||
|
## 6. Function → Component map
|
||||||
|
|
||||||
|
Strength of each Component in realising each Function (9/3/1/blank). Component list and ADR anchors are in §3.
|
||||||
|
|
||||||
|
| | C1 | C2 | C3 | C4 | C5 | C6 |
|
||||||
|
| ------ | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| **F1** | | 3 | 9 | | | |
|
||||||
|
| **F2** | 3 | 9 | | | | |
|
||||||
|
| **F3** | 3 | | | 9 | | |
|
||||||
|
| **F4** | 9 | | | 3 | | |
|
||||||
|
| **F5** | 3 | | 9 | | | |
|
||||||
|
| **F6** | | 9 | 3 | | | |
|
||||||
|
| **F7** | | | 3 | | | 9 |
|
||||||
|
|
||||||
|
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
|
||||||
|
|
||||||
## 7. Critical performance budget
|
## 7. Critical performance budget
|
||||||
|
|
||||||
| Rank | Function | Target | Watched on | If we miss it |
|
| Rank | Function | Target | Watched on | If we miss it |
|
||||||
| ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
| ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
|
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
|
||||||
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
|
| 2 | F1 | reader IDs a state ≤1s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
|
||||||
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
|
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
|
||||||
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |
|
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ Dashboard ┣━━━━━◯ 🔴 n
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | What it covers |
|
| Document | What it covers |
|
||||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| [docs/format.md](docs/format.md) | **The Macroplan TOML format (v1)** — the portable, tool-independent schema: fields, value types, the `macroplan_version` marker, and the render semantics a consumer must honor. Ships a [JSON Schema](public/macroplan.schema.json) and a [reference `.toml`](docs/macroplan.example.toml). |
|
||||||
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
|
| [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 |
|
| [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/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
|
||||||
|
|||||||
154
docs/format.md
Normal file
154
docs/format.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Macroplan TOML Format — v1
|
||||||
|
|
||||||
|
The portable, tool-independent definition of a **Macroplan**: a week-by-week
|
||||||
|
delivery plan that records what a team promised, every time an estimate slipped,
|
||||||
|
and when work actually shipped — judged against the **first** estimate, never a
|
||||||
|
moved goalpost.
|
||||||
|
|
||||||
|
This document is the contract. Any tool can produce or consume a `.toml` file
|
||||||
|
that conforms to it and render the same plan. The [macroplan app](../README.md)
|
||||||
|
is one such consumer; nothing here is specific to it.
|
||||||
|
|
||||||
|
- **Machine-readable schema:** [`macroplan.schema.json`](../public/macroplan.schema.json),
|
||||||
|
served at `https://macroplan.apoena.dev/macroplan.schema.json` (JSON Schema
|
||||||
|
2020-12; validates the TOML-decoded structure).
|
||||||
|
- **Reference file:** [`macroplan.example.toml`](macroplan.example.toml)
|
||||||
|
(exercises every field and state; a conformance sample).
|
||||||
|
- **Serialization:** [TOML v1.0.0](https://toml.io). Values use TOML's native
|
||||||
|
types — dates are **bare date literals**, not quoted strings (see _Dates_).
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
A file MAY declare the format version at the top level:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
macroplan_version = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Optional.** An absent marker means the current version (`1`). Every existing
|
||||||
|
file therefore stays valid.
|
||||||
|
- **Integer**, monotonically increasing. This document defines version **1**.
|
||||||
|
- A consumer that understands version _N_ **MUST reject** a file declaring a
|
||||||
|
version **greater than** _N_ (rather than silently mis-rendering it), and
|
||||||
|
SHOULD accept any version ≤ _N_ it still supports.
|
||||||
|
- The version is bumped only for a **breaking** change to the schema or render
|
||||||
|
semantics below — a new optional field does not require a bump.
|
||||||
|
|
||||||
|
Producers SHOULD emit `macroplan_version` so a file self-describes its target.
|
||||||
|
|
||||||
|
## Top-level fields
|
||||||
|
|
||||||
|
| Key | Required | Type | Default | Meaning |
|
||||||
|
| ------------------- | -------- | ------------- | ---------------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| `macroplan_version` | no | integer (`1`) | `1` | Format version (see above). |
|
||||||
|
| `title` | no | string | `"Untitled Macroplan"` | Display name of the plan. |
|
||||||
|
| `start` | no | date | auto-fit | Left edge of the plan's week span. **Only extends** the span; never clips a feature. |
|
||||||
|
| `end` | no | date | auto-fit | Right edge of the plan's week span. **Only extends** the span; never clips a feature. |
|
||||||
|
|
||||||
|
Zero or more `[[feature]]` blocks and zero or more `[[milestone]]` blocks follow.
|
||||||
|
|
||||||
|
## `[[feature]]`
|
||||||
|
|
||||||
|
A unit of work, read left-to-right as a story: where it started, what was first
|
||||||
|
promised, every slip, and when it shipped.
|
||||||
|
|
||||||
|
| Key | Required | Type | Default | Meaning |
|
||||||
|
| ------------- | -------- | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `name` | **yes** | non-empty string | — | Identifies the feature; the **join key** for a milestone's `requires`. |
|
||||||
|
| `start` | **yes** | date | — | The week work began. |
|
||||||
|
| `original` | **yes** | date | — | The **Original Estimate** — the immovable baseline all lateness is judged against ([ADR-0001](adr/0001-original-estimate-as-baseline.md)). |
|
||||||
|
| `reestimates` | no | array of dates | `[]` | Each later week the estimate slipped to (a slip, `△`). Order-independent. |
|
||||||
|
| `delivered` | no | date | — | The week the feature actually shipped. Absent → still in flight. |
|
||||||
|
| `learning` | no | string | — | A post-delivery takeaway. |
|
||||||
|
| `status` | no | `"on-track"` \| `"at-risk"` \| `"off-track"` | — | In-flight confidence. Meaningful only while undelivered. |
|
||||||
|
| `note` | no | string | — | A short note accompanying `status`. |
|
||||||
|
|
||||||
|
**Constraint — unique names:** feature `name` values MUST be unique within a
|
||||||
|
plan. Because milestone `requires` resolves by name, a duplicate would make a
|
||||||
|
requirement ambiguous. Conforming consumers reject duplicates.
|
||||||
|
|
||||||
|
## `[[milestone]]`
|
||||||
|
|
||||||
|
A dated checkpoint that depends on a set of features being delivered by then.
|
||||||
|
|
||||||
|
| Key | Required | Type | Default | Meaning |
|
||||||
|
| ---------- | -------- | ---------------- | ------- | ------------------------------------------------------------------- |
|
||||||
|
| `name` | **yes** | non-empty string | — | Display name of the milestone. |
|
||||||
|
| `week` | **yes** | date | — | When the milestone falls due. |
|
||||||
|
| `requires` | no | array of strings | `[]` | Feature `name`s that must be delivered on/before `week` to meet it. |
|
||||||
|
|
||||||
|
A `requires` entry that names no existing feature counts as **unmet** (it can
|
||||||
|
never be satisfied) — useful for catching typos.
|
||||||
|
|
||||||
|
## Value types
|
||||||
|
|
||||||
|
### Dates → weeks
|
||||||
|
|
||||||
|
Every date field is a **calendar date**, written as a TOML bare date literal:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
original = 2026-06-15
|
||||||
|
reestimates = [2026-06-29, 2026-07-13]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Canonical form is the bare date literal.** A quoted `"2026-06-15"` string is
|
||||||
|
_tolerated_ on input for convenience, but producers SHOULD emit bare literals
|
||||||
|
so the same plan always serializes identically.
|
||||||
|
- **Any day snaps to its ISO-week Monday.** A Macroplan's unit is the week, keyed
|
||||||
|
by the Monday (yyyy-mm-dd) of the ISO week containing the date. `2026-06-17`
|
||||||
|
(a Wednesday) and `2026-06-15` (that Monday) denote the **same week**. Every
|
||||||
|
conforming renderer MUST apply this snapping, or dates land in the wrong column.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
The enum `on-track` \| `at-risk` \| `off-track`. No other value is valid. Lateness
|
||||||
|
is **derived, never authored** — there is deliberately no "late" status.
|
||||||
|
|
||||||
|
## Render semantics (the contract a renderer must honor)
|
||||||
|
|
||||||
|
Two tools should draw the same plan from the same file. These rules are as much
|
||||||
|
a part of the format as the fields:
|
||||||
|
|
||||||
|
1. **On-time vs. late is judged only against `original`** — never a re-estimate
|
||||||
|
([ADR-0001](adr/0001-original-estimate-as-baseline.md)). `delivered ≤ original`
|
||||||
|
(by week) is on time; `delivered > original` is late.
|
||||||
|
2. **Marker vocabulary** per feature:
|
||||||
|
- `◯` **original** — the Original Estimate week, while undelivered.
|
||||||
|
- `△` **re-estimate** — one per `reestimates` entry.
|
||||||
|
- `◉` **delivered on time** — a delivery on/before `original`. It **subsumes**
|
||||||
|
the `◯` (no separate original marker is drawn).
|
||||||
|
- `▲` **delivered late** — a delivery after `original`; the `◯` baseline is
|
||||||
|
**kept** alongside it.
|
||||||
|
3. **Bar extent.** A feature's bar runs from `start` to its furthest marker. A
|
||||||
|
delivered bar ends at its delivery. An **overdue** feature (undelivered, and
|
||||||
|
already past its furthest estimate relative to "now") keeps running to the
|
||||||
|
current week.
|
||||||
|
4. **Plan span.** The rendered week range runs from the earliest to the latest
|
||||||
|
week among all features' markers and all milestones. Optional `start`/`end`
|
||||||
|
only widen this range with lead-in / trailing weeks; a marker outside them is
|
||||||
|
never clipped.
|
||||||
|
5. **Milestone met/unmet.** A required feature is **unmet** at a milestone if it
|
||||||
|
is undelivered, delivered _after_ the milestone `week`, or names no existing
|
||||||
|
feature.
|
||||||
|
6. **"Now".** The current week (Monday of today) drives overdue extension and the
|
||||||
|
"now" line. It is contextual, not stored in the file.
|
||||||
|
|
||||||
|
## Notes for implementers
|
||||||
|
|
||||||
|
- **Unknown keys.** The JSON Schema is strict (`additionalProperties: false`) so
|
||||||
|
editors flag typos like `titel`. The reference parser is more lenient — it
|
||||||
|
ignores unrecognized top-level keys. Producers SHOULD NOT emit unknown keys.
|
||||||
|
- **Empty plan.** A file with no features and no milestones is valid; with an
|
||||||
|
authored `start`/`end` it renders as an empty grid across that span.
|
||||||
|
- **Editor integration.** Point [Taplo](https://taplo.tamasfe.dev)-based editors
|
||||||
|
at the schema with a directive on the first line of a plan file:
|
||||||
|
`#:schema https://macroplan.apoena.dev/macroplan.schema.json`.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [CONTEXT.md](../CONTEXT.md) — the ubiquitous language (Feature, Original
|
||||||
|
Estimate, Re-estimate, Delivery, Milestone, Week, Status, Learning).
|
||||||
|
- [ADR-0001](adr/0001-original-estimate-as-baseline.md) — why lateness is judged
|
||||||
|
against the Original Estimate.
|
||||||
|
- [ADR-0002](adr/0002-local-first-no-backend.md) — why the `.toml` file is the
|
||||||
|
portable source of truth.
|
||||||
79
docs/macroplan.example.toml
Normal file
79
docs/macroplan.example.toml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#:schema https://macroplan.apoena.dev/macroplan.schema.json
|
||||||
|
#
|
||||||
|
# Reference Macroplan — a conformance sample exercising every field and state.
|
||||||
|
# The authoritative definition of this format is docs/format.md.
|
||||||
|
|
||||||
|
# Format version. Optional; the current version is 1. Omitting it means "1".
|
||||||
|
macroplan_version = 1
|
||||||
|
|
||||||
|
title = "Q3 — Checkout revamp"
|
||||||
|
|
||||||
|
# Optional plan span: pad the plan with lead-in / trailing weeks.
|
||||||
|
# Rule: `start` only ever extends the span left, `end` extends it right;
|
||||||
|
# neither clips a feature. Omit both to auto-fit the columns.
|
||||||
|
start = 2026-05-25
|
||||||
|
end = 2026-08-03
|
||||||
|
|
||||||
|
# Dates are TOML date literals. Any day snaps to that week's Monday.
|
||||||
|
|
||||||
|
# On-time delivery: delivered on/before the Original Estimate → ◉ (subsumes ◯).
|
||||||
|
[[feature]]
|
||||||
|
name = "Auth"
|
||||||
|
start = 2026-06-01
|
||||||
|
original = 2026-06-15
|
||||||
|
delivered = 2026-06-15
|
||||||
|
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
|
||||||
|
|
||||||
|
# Late delivery with slips: ◯ baseline kept, one △ per re-estimate, then ▲.
|
||||||
|
# Late because delivery (07-20) is after `original` (06-15) — the re-estimates
|
||||||
|
# never move the goalpost (ADR-0001).
|
||||||
|
[[feature]]
|
||||||
|
name = "Payments"
|
||||||
|
start = 2026-06-01
|
||||||
|
original = 2026-06-15
|
||||||
|
reestimates = [2026-06-29, 2026-07-13]
|
||||||
|
delivered = 2026-07-20
|
||||||
|
learning = "Vendor lead time was the real constraint — derisk vendors up front."
|
||||||
|
|
||||||
|
# In-flight and overdue: undelivered, `original` already in the past → ◯ only,
|
||||||
|
# bar runs to "now". `status` + `note` express confidence while it's open.
|
||||||
|
[[feature]]
|
||||||
|
name = "Dashboard"
|
||||||
|
start = 2026-06-01
|
||||||
|
original = 2026-06-08
|
||||||
|
status = "off-track"
|
||||||
|
note = "No recovery plan yet — needs an owner."
|
||||||
|
|
||||||
|
# In-flight, re-estimated once: ◯ + △, still open.
|
||||||
|
[[feature]]
|
||||||
|
name = "Search"
|
||||||
|
start = 2026-06-08
|
||||||
|
original = 2026-06-22
|
||||||
|
reestimates = [2026-07-06]
|
||||||
|
status = "at-risk"
|
||||||
|
note = "Third-party search API is flaky; spike a fallback."
|
||||||
|
|
||||||
|
# In-flight, on track: the minimal feature — only the three required fields
|
||||||
|
# plus a status.
|
||||||
|
[[feature]]
|
||||||
|
name = "Notifications"
|
||||||
|
start = 2026-06-22
|
||||||
|
original = 2026-07-06
|
||||||
|
status = "on-track"
|
||||||
|
|
||||||
|
# Milestones tie a due week to the features that must ship by then.
|
||||||
|
[[milestone]]
|
||||||
|
name = "Code freeze"
|
||||||
|
week = 2026-06-29
|
||||||
|
requires = ["Auth", "Payments"]
|
||||||
|
|
||||||
|
[[milestone]]
|
||||||
|
name = "MVP go-live"
|
||||||
|
week = 2026-07-06
|
||||||
|
requires = ["Auth", "Payments", "Dashboard"]
|
||||||
|
|
||||||
|
# A milestone with no requirements is valid too.
|
||||||
|
[[milestone]]
|
||||||
|
name = "Beta launch"
|
||||||
|
week = 2026-07-13
|
||||||
|
requires = ["Search", "Notifications"]
|
||||||
108
public/macroplan.schema.json
Normal file
108
public/macroplan.schema.json
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://macroplan.apoena.dev/macroplan.schema.json",
|
||||||
|
"title": "Macroplan",
|
||||||
|
"description": "A week-by-week delivery plan. Portable TOML format v1 — see docs/format.md. Validates the TOML-decoded structure; dates are TOML date literals, surfaced to JSON Schema as date-formatted strings.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"macroplan_version": {
|
||||||
|
"description": "Format version. Omit for the current version (1). A newer version must be rejected by a consumer that only understands 1.",
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"description": "Display name of the plan. Defaults to \"Untitled Macroplan\" when absent.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"description": "Optional left edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"description": "Optional right edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"description": "The features of the plan. Written as [[feature]] blocks. Names must be unique.",
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/feature" }
|
||||||
|
},
|
||||||
|
"milestone": {
|
||||||
|
"description": "Dated checkpoints. Written as [[milestone]] blocks.",
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/milestone" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"date": {
|
||||||
|
"description": "A calendar date (TOML date literal, e.g. 2026-06-15). Snapped to its ISO-week Monday.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "start", "original"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "Identifies the feature; the join key for a milestone's `requires`. Must be unique within the plan.",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"description": "The week work began.",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"description": "The Original Estimate — the immovable baseline all lateness is judged against (ADR-0001).",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"reestimates": {
|
||||||
|
"description": "Each later week the estimate slipped to. Order-independent.",
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/date" }
|
||||||
|
},
|
||||||
|
"delivered": {
|
||||||
|
"description": "The week the feature actually shipped. Absent means still in flight.",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"learning": {
|
||||||
|
"description": "A post-delivery takeaway.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "In-flight confidence. Meaningful only while undelivered. Lateness is derived, never authored.",
|
||||||
|
"enum": ["on-track", "at-risk", "off-track"]
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"description": "A short note accompanying `status`.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"milestone": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "week"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"description": "Display name of the milestone.",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"week": {
|
||||||
|
"description": "When the milestone falls due.",
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"description": "Feature names that must be delivered on/before `week` to meet the milestone.",
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,4 +179,12 @@ function confirmDelete() {
|
|||||||
.export-root.exporting :deep(.macroplan) {
|
.export-root.exporting :deep(.macroplan) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
|
||||||
|
status notes) must wrap to its full length in the capture instead of being cut.
|
||||||
|
The column width is unchanged, so the milestone band stays aligned. */
|
||||||
|
.export-root.exporting :deep(.namecell .truncate),
|
||||||
|
.export-root.exporting :deep(.learncell .note) {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
||||||
const NAME_W = 9 * 16
|
const NAME_W = 16 * 16
|
||||||
const WK = 3.5 * 16
|
const WK = 3.5 * 16
|
||||||
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.macroplan {
|
.macroplan {
|
||||||
--name-w: 9rem;
|
--name-w: 16rem;
|
||||||
--wk: 3.5rem;
|
--wk: 3.5rem;
|
||||||
}
|
}
|
||||||
.plan-grid {
|
.plan-grid {
|
||||||
|
|||||||
74
src/components/PlanEditor.test.ts
Normal file
74
src/components/PlanEditor.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeAll } from "vitest"
|
||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import PlanEditor from "./PlanEditor.vue"
|
||||||
|
|
||||||
|
// happy-dom ships no canvas 2D context; measure() only needs measureText, so a
|
||||||
|
// tiny stub keeps onMounted from throwing without pulling in a canvas polyfill.
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||||
|
measureText: () => ({ width: 80 }),
|
||||||
|
font: "",
|
||||||
|
})) as unknown as HTMLCanvasElement["getContext"]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Mount with a working v-model so emitted edits flow back into the prop. */
|
||||||
|
function mountEditor(initial = "") {
|
||||||
|
const wrapper = mount(PlanEditor, {
|
||||||
|
props: {
|
||||||
|
modelValue: initial,
|
||||||
|
error: null,
|
||||||
|
"onUpdate:modelValue": (v: string) => wrapper.setProps({ modelValue: v }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type `text` into the textarea (caret at the end) and let the popup refresh. */
|
||||||
|
async function type(wrapper: ReturnType<typeof mountEditor>, text: string) {
|
||||||
|
const ta = wrapper.find("textarea")
|
||||||
|
const el = ta.element as HTMLTextAreaElement
|
||||||
|
el.value = text
|
||||||
|
el.selectionStart = el.selectionEnd = text.length
|
||||||
|
await ta.trigger("input")
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastEmit = (wrapper: ReturnType<typeof mountEditor>) => {
|
||||||
|
const events = wrapper.emitted("update:modelValue")
|
||||||
|
return events?.at(-1)?.[0] as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlanEditor completion keys", () => {
|
||||||
|
it("opens the popup as the author types a key prefix", async () => {
|
||||||
|
const wrapper = mountEditor()
|
||||||
|
await type(wrapper, "t")
|
||||||
|
expect(wrapper.find(".completion").exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll(".completion .label").map((el) => el.text())).toContain("title")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("Tab accepts the selected item without navigating first", async () => {
|
||||||
|
const wrapper = mountEditor()
|
||||||
|
await type(wrapper, "t")
|
||||||
|
await wrapper.find("textarea").trigger("keydown", { key: "Tab" })
|
||||||
|
expect(lastEmit(wrapper)).toBe("title = ")
|
||||||
|
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("Enter accepts the selected item without navigating first", async () => {
|
||||||
|
const wrapper = mountEditor()
|
||||||
|
await type(wrapper, "t")
|
||||||
|
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
|
||||||
|
expect(lastEmit(wrapper)).toBe("title = ")
|
||||||
|
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("Escape dismisses the popup so the next Enter is a newline, not an accept", async () => {
|
||||||
|
const wrapper = mountEditor()
|
||||||
|
await type(wrapper, "t")
|
||||||
|
await wrapper.find("textarea").trigger("keydown", { key: "Escape" })
|
||||||
|
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||||
|
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
|
||||||
|
// Popup is gone, so Enter accepts nothing — the value stays as typed.
|
||||||
|
expect(lastEmit(wrapper)).toBe("t")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
import { ref, computed, onMounted, nextTick } from "vue"
|
import { ref, computed, onMounted, nextTick } from "vue"
|
||||||
import type { HighlighterCore } from "shiki/core"
|
import type { HighlighterCore } from "shiki/core"
|
||||||
import { getCompletions, type CompletionContext } from "./completion"
|
import { getCompletions, type CompletionContext } from "./completion"
|
||||||
|
import { fitPopup } from "./popupFit"
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||||||
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
|
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
|
||||||
|
|
||||||
const textarea = ref<HTMLTextAreaElement>()
|
const textarea = ref<HTMLTextAreaElement>()
|
||||||
const backdrop = ref<HTMLElement>()
|
const backdrop = ref<HTMLElement>()
|
||||||
|
const popupEl = ref<HTMLElement>()
|
||||||
const highlighter = ref<HighlighterCore>()
|
const highlighter = ref<HighlighterCore>()
|
||||||
|
|
||||||
// ── Completion popup state ───────────────────────────────────────────────────
|
// ── Completion popup state ───────────────────────────────────────────────────
|
||||||
@@ -70,10 +72,29 @@ function position() {
|
|||||||
const line = before.slice(before.lastIndexOf("\n") + 1)
|
const line = before.slice(before.lastIndexOf("\n") + 1)
|
||||||
let col = 0
|
let col = 0
|
||||||
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
|
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
|
||||||
popup.value = {
|
const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
|
||||||
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
|
const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
|
||||||
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
|
popup.value = { left: caretLeft, top: caretTop + metrics.lineHeight }
|
||||||
}
|
// The popup has no measurable size until Vue has rendered it; adjust once it does.
|
||||||
|
nextTick(() => keepInView(caretLeft, caretTop))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nudge the popup back inside the window once it has a measurable size. */
|
||||||
|
function keepInView(caretLeft: number, caretTop: number) {
|
||||||
|
const el = textarea.value
|
||||||
|
const list = popupEl.value
|
||||||
|
if (!el || !list) return
|
||||||
|
// popup left/top are relative to `.code`, which the textarea fills exactly, so
|
||||||
|
// the textarea rect bridges those local coordinates to the window.
|
||||||
|
const ta = el.getBoundingClientRect()
|
||||||
|
const box = list.getBoundingClientRect()
|
||||||
|
popup.value = fitPopup({
|
||||||
|
caret: { left: caretLeft, top: caretTop },
|
||||||
|
lineHeight: metrics.lineHeight,
|
||||||
|
box: { width: box.width, height: box.height },
|
||||||
|
origin: { left: ta.left, top: ta.top },
|
||||||
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function accept(i: number) {
|
function accept(i: number) {
|
||||||
@@ -115,6 +136,8 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
break
|
break
|
||||||
case "Enter":
|
case "Enter":
|
||||||
case "Tab":
|
case "Tab":
|
||||||
|
// Both accept the highlighted item whenever the popup is open. To insert a
|
||||||
|
// newline instead, dismiss the popup with Escape first, then press Enter.
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
accept(selected.value)
|
accept(selected.value)
|
||||||
break
|
break
|
||||||
@@ -184,6 +207,7 @@ function syncScroll() {
|
|||||||
></textarea>
|
></textarea>
|
||||||
<ul
|
<ul
|
||||||
v-if="completion"
|
v-if="completion"
|
||||||
|
ref="popupEl"
|
||||||
class="completion"
|
class="completion"
|
||||||
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("completion context", () => {
|
describe("completion context", () => {
|
||||||
it("offers plan keys and block headers on an empty top-level line", () => {
|
it("offers plan keys on an empty top-level line", () => {
|
||||||
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
|
// Headers wait for a blank line above; at the very top there's nothing above.
|
||||||
|
expect(labelsAt("|")).toEqual(["title", "start", "end"])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("offers feature keys inside a [[feature]] block", () => {
|
it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
|
||||||
expect(labelsAt("[[feature]]\n|")).toEqual([
|
expect(labelsAt("[[feature]]\n|")).toEqual([
|
||||||
"name",
|
"name",
|
||||||
"start",
|
"start",
|
||||||
@@ -28,14 +29,27 @@ describe("completion context", () => {
|
|||||||
"status",
|
"status",
|
||||||
"learning",
|
"learning",
|
||||||
"note",
|
"note",
|
||||||
"[[feature]]",
|
|
||||||
"[[milestone]]",
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("offers milestone keys inside a [[milestone]] block", () => {
|
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
|
||||||
expect(labelsAt("[[milestone]]\n|")).toEqual([
|
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
|
||||||
"name",
|
})
|
||||||
|
|
||||||
|
it("keeps offering remaining keys on a single new line, still without headers", () => {
|
||||||
|
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("volunteers block headers only after a blank line (two new lines)", () => {
|
||||||
|
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
|
||||||
|
// Directly under the last property: nothing left to add ⇒ no popup.
|
||||||
|
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
|
||||||
|
// A blank line signals a new block is wanted ⇒ offer the headers.
|
||||||
|
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("volunteers headers after a blank line even with keys still open", () => {
|
||||||
|
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
|
||||||
"week",
|
"week",
|
||||||
"requires",
|
"requires",
|
||||||
"[[feature]]",
|
"[[feature]]",
|
||||||
@@ -143,3 +157,25 @@ describe("completion replace range", () => {
|
|||||||
expect(ctx.items[0].insert).toBe("[[feature]]")
|
expect(ctx.items[0].insert).toBe("[[feature]]")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("bracket header block separation", () => {
|
||||||
|
/** The inserts offered at the `|` marker. */
|
||||||
|
const insertsAt = (marked: string) => {
|
||||||
|
const caret = marked.indexOf("|")
|
||||||
|
const source = marked.slice(0, caret) + marked.slice(caret + 1)
|
||||||
|
return getCompletions(source, caret)!.items.map((i) => i.insert)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("prepends a blank line when a bare [ glues onto the block above", () => {
|
||||||
|
// No blank line above ⇒ accepting must not weld the new block to the old one.
|
||||||
|
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds no leading newline when a blank line already separates the block", () => {
|
||||||
|
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds no leading newline at the very top of the document", () => {
|
||||||
|
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -103,12 +103,21 @@ export function getCompletions(
|
|||||||
if (key) {
|
if (key) {
|
||||||
const [, indent, brackets, word] = key
|
const [, indent, brackets, word] = key
|
||||||
if (brackets) {
|
if (brackets) {
|
||||||
return result(lineStart + indent.length, caret, filter(HEADERS, word))
|
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
|
||||||
}
|
}
|
||||||
const keys =
|
const keys =
|
||||||
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
||||||
const taken = presentKeys(source, lineStart, block)
|
const taken = presentKeys(source, lineStart, block)
|
||||||
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
|
const remaining = keys.filter((k) => !taken.has(k.label))
|
||||||
|
// Volunteer a new block header only once there's a blank line above: a new
|
||||||
|
// [[…]] wants a gap before it, so offering one right under the last property
|
||||||
|
// (or straight after the header line) pops the menu up too eagerly. Typing a
|
||||||
|
// bare `[` still summons headers anywhere (handled above). With all keys set
|
||||||
|
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
|
||||||
|
const items = filter(
|
||||||
|
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
|
||||||
|
word,
|
||||||
|
)
|
||||||
return result(caret - word.length, caret, items)
|
return result(caret - word.length, caret, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +128,23 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
|
|||||||
return items.length ? { from, to, items } : null
|
return items.length ? { from, to, items } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header completions for a bare `[`. When the block above isn't already set off
|
||||||
|
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
|
||||||
|
* never glues the new block onto the previous one. (At the top of the document
|
||||||
|
* there's nothing to separate, so no gap is added.) */
|
||||||
|
function headerItems(source: string, lineStart: number): Completion[] {
|
||||||
|
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
|
||||||
|
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the line directly above the caret's line blank (empty or whitespace only)?
|
||||||
|
* Gates header suggestions to the "two new lines" rule — see the key branch. */
|
||||||
|
function blankLineAbove(source: string, lineStart: number): boolean {
|
||||||
|
if (lineStart === 0) return false // top of the document — nothing above
|
||||||
|
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
|
||||||
|
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
|
||||||
|
}
|
||||||
|
|
||||||
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
||||||
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
||||||
function todayYmd(): string {
|
function todayYmd(): string {
|
||||||
|
|||||||
54
src/components/popupFit.test.ts
Normal file
54
src/components/popupFit.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { fitPopup } from "./popupFit"
|
||||||
|
|
||||||
|
// A roomy window with the editor pinned at the top-left, so `origin` adds nothing
|
||||||
|
// and only the caret/box geometry drives the result.
|
||||||
|
const base = {
|
||||||
|
lineHeight: 20,
|
||||||
|
box: { width: 256, height: 224 },
|
||||||
|
origin: { left: 0, top: 0 },
|
||||||
|
viewport: { width: 1000, height: 800 },
|
||||||
|
margin: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fitPopup", () => {
|
||||||
|
it("anchors just below the caret when it fits", () => {
|
||||||
|
const { left, top } = fitPopup({ ...base, caret: { left: 100, top: 200 } })
|
||||||
|
expect(left).toBe(100)
|
||||||
|
expect(top).toBe(220) // caret.top + lineHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shifts left so the right edge clears the window", () => {
|
||||||
|
// caret.left 900 + box 256 = 1156, past the 992 usable width by 164.
|
||||||
|
const { left } = fitPopup({ ...base, caret: { left: 900, top: 200 } })
|
||||||
|
expect(left).toBe(736) // 900 - 164
|
||||||
|
expect(left + base.box.width).toBe(base.viewport.width - base.margin)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flips above the caret line when there's no room below", () => {
|
||||||
|
// caret near the bottom: below would be 780+224 = 1004, past 792.
|
||||||
|
const { top } = fitPopup({ ...base, caret: { left: 100, top: 780 } })
|
||||||
|
expect(top).toBe(780 - 224) // popup bottom now sits on the caret line
|
||||||
|
})
|
||||||
|
|
||||||
|
it("never pushes the flipped popup off the top of the window", () => {
|
||||||
|
// Tiny window: neither below nor a full flip fits, so clamp to the margin.
|
||||||
|
const { top } = fitPopup({
|
||||||
|
...base,
|
||||||
|
caret: { left: 100, top: 40 },
|
||||||
|
viewport: { width: 1000, height: 120 },
|
||||||
|
})
|
||||||
|
expect(top).toBe(base.margin)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accounts for the editor's offset within the window (origin)", () => {
|
||||||
|
// Editor pushed 800px right; a caret at local 100 sits at window x=900, so the
|
||||||
|
// 256-wide box overflows and must shift left even though caret.left is small.
|
||||||
|
const { left } = fitPopup({
|
||||||
|
...base,
|
||||||
|
caret: { left: 100, top: 200 },
|
||||||
|
origin: { left: 800, top: 0 },
|
||||||
|
})
|
||||||
|
expect(800 + left + base.box.width).toBe(base.viewport.width - base.margin)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
src/components/popupFit.ts
Normal file
33
src/components/popupFit.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/** Geometry for keeping the completion popup inside the browser window.
|
||||||
|
*
|
||||||
|
* The popup is anchored just below the caret. Near the right or bottom edge of
|
||||||
|
* the window that anchor spills off-screen and the popup gets clipped, so we:
|
||||||
|
* • shift it left until its right edge clears the window, and
|
||||||
|
* • flip it above the caret line when there's no room below.
|
||||||
|
*
|
||||||
|
* Coordinates are local to the editor (the textarea's border box); `origin` is
|
||||||
|
* that box's own top-left in the window, the bridge from local to window space.
|
||||||
|
*/
|
||||||
|
export function fitPopup(opts: {
|
||||||
|
caret: { left: number; top: number }
|
||||||
|
lineHeight: number
|
||||||
|
box: { width: number; height: number }
|
||||||
|
origin: { left: number; top: number }
|
||||||
|
viewport: { width: number; height: number }
|
||||||
|
margin?: number
|
||||||
|
}): { left: number; top: number } {
|
||||||
|
const { caret, lineHeight, box, origin, viewport } = opts
|
||||||
|
const margin = opts.margin ?? 8
|
||||||
|
|
||||||
|
let left = caret.left
|
||||||
|
const overRight = origin.left + left + box.width - (viewport.width - margin)
|
||||||
|
if (overRight > 0) left = Math.max(margin - origin.left, left - overRight)
|
||||||
|
|
||||||
|
let top = caret.top + lineHeight
|
||||||
|
if (origin.top + top + box.height > viewport.height - margin) {
|
||||||
|
// Not enough room below — flip above, but never off the top of the window.
|
||||||
|
top = Math.max(margin - origin.top, caret.top - box.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, top }
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
// Default Macroplan shown on first load — exercises every state:
|
// Default Macroplan shown on first load — exercises every state:
|
||||||
// on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track),
|
// on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track),
|
||||||
// an overdue Feature, and a Milestone with unmet required Features.
|
// an overdue Feature, and a Milestone with unmet required Features.
|
||||||
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
|
export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md.
|
||||||
|
macroplan_version = 1
|
||||||
|
|
||||||
|
title = "Q3 — Checkout revamp"
|
||||||
|
|
||||||
# Optional plan span: pad the plan with lead-in / trailing weeks.
|
# Optional plan span: pad the plan with lead-in / trailing weeks.
|
||||||
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
|
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ const MilestoneSchema = v.object({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** The Macroplan format version this build understands (see docs/format.md).
|
||||||
|
* Files may declare `macroplan_version`; a newer version is rejected rather
|
||||||
|
* than silently mis-rendered against a schema we don't know. */
|
||||||
|
export const FORMAT_VERSION = 1
|
||||||
|
|
||||||
/** Parse + validate a Macroplan TOML source into the raw model. */
|
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||||
export function parseMacroplan(source: string): RawPlan {
|
export function parseMacroplan(source: string): RawPlan {
|
||||||
let data: Record<string, unknown>
|
let data: Record<string, unknown>
|
||||||
@@ -54,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan {
|
|||||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkVersion(data.macroplan_version)
|
||||||
|
|
||||||
|
const features = asBlocks(data.feature, "feature").map((f, i) =>
|
||||||
|
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||||
|
)
|
||||||
|
requireUniqueFeatureNames(features)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title != null ? String(data.title) : "Untitled Macroplan",
|
title: data.title != null ? String(data.title) : "Untitled Macroplan",
|
||||||
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
|
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
|
||||||
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
||||||
features: asBlocks(data.feature, "feature").map((f, i) =>
|
features,
|
||||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
|
||||||
),
|
|
||||||
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
||||||
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate the optional `macroplan_version` marker. Absent means the current
|
||||||
|
* version; a future version we don't understand is rejected rather than
|
||||||
|
* silently mis-rendered. */
|
||||||
|
function checkVersion(value: unknown): void {
|
||||||
|
if (value == null) return
|
||||||
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||||
|
throw new PlanParseError("`macroplan_version` must be a positive integer")
|
||||||
|
}
|
||||||
|
if (value > FORMAT_VERSION) {
|
||||||
|
throw new PlanParseError(
|
||||||
|
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feature names are the join key for a Milestone's `requires`, so they must be
|
||||||
|
* unique — otherwise a requirement resolves ambiguously to the first match. */
|
||||||
|
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const f of features) {
|
||||||
|
if (seen.has(f.name)) {
|
||||||
|
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
|
||||||
|
}
|
||||||
|
seen.add(f.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function asBlocks(value: unknown, key: string): unknown[] {
|
function asBlocks(value: unknown, key: string): unknown[] {
|
||||||
if (value == null) return []
|
if (value == null) return []
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest"
|
||||||
|
import exampleToml from "../../docs/macroplan.example.toml?raw"
|
||||||
import { parseMacroplan, PlanParseError } from "./parse"
|
import { parseMacroplan, PlanParseError } from "./parse"
|
||||||
import { buildPlan } from "./plan"
|
import { buildPlan } from "./plan"
|
||||||
import { mondayOf, weekRange } from "./week"
|
import { mondayOf, weekRange } from "./week"
|
||||||
@@ -181,3 +182,42 @@ describe("parse validation", () => {
|
|||||||
expect(raw.milestones).toHaveLength(3)
|
expect(raw.milestones).toHaveLength(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("format version (macroplan_version)", () => {
|
||||||
|
const feature = '[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n'
|
||||||
|
|
||||||
|
it("accepts an explicit current version", () => {
|
||||||
|
expect(() => parseMacroplan(`macroplan_version = 1\n${feature}`)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts an absent version (defaults to current)", () => {
|
||||||
|
expect(() => parseMacroplan(feature)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a future/unsupported version rather than mis-rendering it", () => {
|
||||||
|
expect(() => parseMacroplan(`macroplan_version = 2\n${feature}`)).toThrow(/unsupported/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a non-integer version", () => {
|
||||||
|
expect(() => parseMacroplan(`macroplan_version = 1.5\n${feature}`)).toThrow(/macroplan_version/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("feature-name uniqueness", () => {
|
||||||
|
it("rejects duplicate feature names (they are the milestone join key)", () => {
|
||||||
|
const dup =
|
||||||
|
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n' +
|
||||||
|
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
|
||||||
|
expect(() => parseMacroplan(dup)).toThrow(/duplicate/)
|
||||||
|
expect(() => parseMacroplan(dup)).toThrow(PlanParseError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("reference example (docs/macroplan.example.toml)", () => {
|
||||||
|
it("conforms to the format the parser implements", () => {
|
||||||
|
const raw = parseMacroplan(exampleToml)
|
||||||
|
expect(raw.title).toBe("Q3 — Checkout revamp")
|
||||||
|
expect(raw.features).toHaveLength(5)
|
||||||
|
expect(raw.milestones).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user