Compare commits
61 Commits
629fa87ec1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34219a6bdc | ||
|
|
fbfbbdc0be | ||
|
|
b17de475ff | ||
|
|
f7e856b082 | ||
|
|
8c14ae9c42 | ||
|
|
f535c9ede5 | ||
|
|
616a4c4301 | ||
|
|
83f04af8d7 | ||
|
|
53716a13ea | ||
|
|
d0a34ef28a | ||
|
|
e380e6d336 | ||
|
|
9ee7c05019 | ||
|
|
16b07844a8 | ||
|
|
f20935a85b | ||
|
|
96c3f489a3 | ||
|
|
449be7766e | ||
|
|
db77314a88 | ||
|
|
c72a95d942 | ||
|
|
e392eca51b | ||
|
|
e9c9f04d68 | ||
|
|
122147c19c | ||
|
|
b6f7bcab82 | ||
|
|
be86b82823 | ||
|
|
e726675d77 | ||
|
|
5a4fbb452d | ||
|
|
d26ebeef71 | ||
|
|
e42987a070 | ||
|
|
289dde523f | ||
|
|
d2f2c949c9 | ||
|
|
65726a0211 | ||
|
|
f42cb2b779 | ||
|
|
120a329421 | ||
|
|
0ac212a4ac | ||
|
|
abce8b65cd | ||
|
|
3d7175006e | ||
|
|
4a8395abb6 | ||
|
|
7245211b62 | ||
|
|
40ee9f509c | ||
|
|
64efc91392 | ||
|
|
362a448848 | ||
|
|
e2f76195dd | ||
|
|
69932fa2bf | ||
|
|
ba5442c58f | ||
|
|
7468ebd3c9 | ||
|
|
3a012f186d | ||
|
|
1c986e417f | ||
|
|
3c32542a76 | ||
|
|
35c84cf943 | ||
|
|
b876bca083 | ||
|
|
c4765d9630 | ||
|
|
59bedda78b | ||
|
|
7c2da0f7ee | ||
|
|
d499433f93 | ||
|
|
d95f0a78c7 | ||
|
|
f37122eea1 | ||
|
|
2173683535 | ||
|
|
ca58ccb245 | ||
|
|
ead0d3158a | ||
|
|
80003077d3 | ||
|
|
ab28e2541a | ||
|
|
8024f950e1 |
53
.github/workflows/deploy-pages.yml
vendored
Normal file
53
.github/workflows/deploy-pages.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow the deploy job to publish to Pages via OIDC.
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Only one Pages deployment at a time; cancel superseded runs.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Served from a custom domain at the root, so no --base override.
|
||||
- run: pnpm build
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
6
.oxfmtrc.json
Normal file
6
.oxfmtrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
11
.oxlintrc.json
Normal file
11
.oxlintrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "oxc"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2024": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
40
.zed/settings.json
Normal file
40
.zed/settings.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"languages": {
|
||||
"TypeScript": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"TSX": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"JavaScript": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Vue.js": {
|
||||
"format_on_save": "on",
|
||||
"formatter": {
|
||||
"external": {
|
||||
"command": "./node_modules/.bin/oxfmt",
|
||||
"arguments": ["--stdin-filepath", "{buffer_path}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
CONTEXT.md
17
CONTEXT.md
@@ -1,11 +1,11 @@
|
||||
# Macroplan
|
||||
|
||||
A **Macroplan** is a week-granular view of the features we've committed to deliver — a simpler cousin of a Gantt chart whose purpose is not just scheduling but *learning* from how our estimates held up over time.
|
||||
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
|
||||
|
||||
**Macroplan**:
|
||||
A week-granular plan of the **Features** we've committed to deliver, read as a record of how estimates held against reality.
|
||||
A week-by-week plan that keeps the receipts on the **Features** we promised: when each actually landed, and what the gap taught us.
|
||||
_Avoid_: Gantt, roadmap, timeline
|
||||
|
||||
**Feature**:
|
||||
@@ -33,17 +33,25 @@ 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): **green** (all good), **orange** (in trouble but we have a plan), **red** (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 orange/red 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**:
|
||||
A column of the plan: one real calendar week, identified and labelled by the date of its first workday (Monday). Columns run contiguously from the earliest Feature start to the last marker or Milestone — empty weeks in between are still drawn.
|
||||
A column of the plan: one real calendar week, identified and labelled by the date of its first workday (Monday). Columns run contiguously from the earliest Feature start to the last marker or Milestone — empty weeks in between are still drawn. An optional authored **start**/**end** widens this span with lead-in or trailing empty Weeks; it only ever extends the range, never narrowing it or hiding a Feature.
|
||||
_Avoid_: column, period, sprint
|
||||
|
||||
**Now line**:
|
||||
A vertical line marking the current week across the whole plan — the at-a-glance "where are we right now".
|
||||
_Avoid_: today marker, cursor
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
- `┣` start of a Feature's bar
|
||||
@@ -56,6 +64,7 @@ _Avoid_: today marker, cursor
|
||||
## Relationships
|
||||
|
||||
- A **Macroplan** contains a flat, author-ordered list of **Features** (typically ordered by start **Week**) and many **Milestones**. There is no grouping/workstream concept.
|
||||
- The **Library** holds many **Macroplans**, exactly one of which is active (shown in the editor and grid). Each is identified internally by a stable id and labelled by its **title**.
|
||||
- A **Feature** has exactly one **Original Estimate**, zero or more **Re-estimates**, at most one **Delivery**, and at most one **Learning**.
|
||||
- A **Milestone** explicitly names the **Features** required by it; a Feature may be required by zero, one, or several Milestones, and a Feature may be in the plan without belonging to any Milestone.
|
||||
- On-time vs. late is judged against the **Original Estimate**, never a **Re-estimate**.
|
||||
|
||||
330
DESIGN.md
330
DESIGN.md
@@ -1,133 +1,37 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 ≤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)
|
||||
## House of Quality
|
||||
|
||||
```tikz
|
||||
% =====================================================================
|
||||
% QFD "House of Quality" preamble
|
||||
% =====================================================================
|
||||
% Usage:
|
||||
% \begin{document}
|
||||
% \begin{qfdhouse}
|
||||
% % WHATs at ({\qfdLeftEdge + 0.1}, {-\r + 0.5})
|
||||
% % Importance at ({-\qfdImpW/2}, {-\r + 0.5})
|
||||
% % HOWs rotated 90 at ({\c - 0.5}, 0.15)
|
||||
% % Relations in cells with [qfdrel/S], [qfdrel/M], [qfdrel/W]
|
||||
% % Roof correlations at (C-i-j)
|
||||
% % Perception markers at ({\qfdNH + (s+0.5)*\qfdCmpW/6}, {-\r + 0.5})
|
||||
% % Basement: target / difficulty / abs weight / rel weight, per HOW
|
||||
% \end{qfdhouse}
|
||||
% \end{document}
|
||||
%
|
||||
% NB: in node text, use $<$ / $>$ for less-than / greater-than under
|
||||
% OT1 encoding; bare < and > render as Spanish ¡ ¿.
|
||||
% =====================================================================
|
||||
|
||||
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
|
||||
|
||||
% Toggles — flip before \begin{qfdhouse} to hide sections.
|
||||
\newif\ifqfdshowroof \qfdshowrooftrue
|
||||
\newif\ifqfdshowbasement \qfdshowbasementtrue
|
||||
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
|
||||
@@ -135,27 +39,36 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\newif\ifqfdshowimportance \qfdshowimportancetrue
|
||||
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
|
||||
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
|
||||
\newif\ifqfdshowtitle \qfdshowtitletrue % title block above the roof
|
||||
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{5}
|
||||
\def\qfdWhatW{4.0}
|
||||
\def\qfdImpW{0.9}
|
||||
\def\qfdCmpW{3}
|
||||
\def\qfdHdrH{2.6}
|
||||
\def\qfdBasementN{4}
|
||||
% Dimensions — override before \begin{qfdhouse} to resize.
|
||||
\def\qfdNW{5} % number of WHATs (rows)
|
||||
\def\qfdNH{5} % number of HOWs (columns)
|
||||
\def\qfdWhatW{4.0} % width of WHATs column
|
||||
\def\qfdImpW{0.9} % width of importance column
|
||||
\def\qfdCmpW{3} % width of perception zone
|
||||
\def\qfdHdrH{2.6} % height of column-titles band
|
||||
\def\qfdBasementN{4} % number of basement rows
|
||||
|
||||
% Titles & labels — override before \begin{qfdhouse}.
|
||||
\def\qfdWhatsTitle{Customer needs}
|
||||
\def\qfdImpTitle{Imp.\ \%}
|
||||
\def\qfdPerceptionTitle{Comparative evaluation}
|
||||
\def\qfdPoorLabel{poor}
|
||||
\def\qfdExcellentLabel{excellent}
|
||||
\def\qfdAltOneLabel{Our product}
|
||||
\def\qfdAltOneLabel{Our product} % highlighted in legend
|
||||
\def\qfdAltTwoLabel{Competitor A}
|
||||
\def\qfdAltThreeLabel{Competitor B}
|
||||
\def\qfdRelTitle{Relation}
|
||||
\def\qfdCorrTitle{Correlation}
|
||||
\def\qfdEvalTitle{Evaluation}
|
||||
|
||||
% Title block above the roof — set both to name the house. Leave
|
||||
% \qfdProjectTitle empty (default) to draw no title at all.
|
||||
\def\qfdProjectTitle{} % project / feature name (large, bold)
|
||||
\def\qfdConcept{} % concept in one sentence; \textbf{} the keywords
|
||||
|
||||
% Styles.
|
||||
\tikzset{
|
||||
qfdthin/.style ={line width=0.35pt},
|
||||
qfdmed/.style ={line width=0.7pt},
|
||||
@@ -169,6 +82,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
qfdrel/S/.style={qfdstrong},
|
||||
qfdrel/M/.style={qfdmod},
|
||||
qfdrel/W/.style={qfdweak},
|
||||
% Three perception-zone alternatives. Index 1 is emphasised.
|
||||
qfdalt1mk/.style={circle, draw, fill=black,
|
||||
minimum size=6pt, inner sep=0pt, line width=1pt},
|
||||
qfdalt1ln/.style={line width=1.2pt},
|
||||
@@ -181,6 +95,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
qfdalt3ln/.style={line width=0.7pt, dotted},
|
||||
}
|
||||
|
||||
% --- Grid lines for every zone. ---
|
||||
\newcommand{\qfdDrawGrid}{%
|
||||
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
|
||||
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
|
||||
@@ -202,6 +117,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\fi
|
||||
}
|
||||
|
||||
% --- Roof: diagonal grid + named coordinates (C-i-j) for correlations. ---
|
||||
\newcommand{\qfdDrawRoof}{%
|
||||
\ifqfdshowroof
|
||||
\foreach \k in {1,...,\qfdNHm} {%
|
||||
@@ -226,6 +142,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\fi
|
||||
}
|
||||
|
||||
% --- Perception scale 0..5 + poor/excellent endpoints + zone title. ---
|
||||
\newcommand{\qfdDrawScale}{%
|
||||
\ifqfdshowcompetitive
|
||||
\foreach \tk in {0,1,2,3,4,5} {%
|
||||
@@ -241,6 +158,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\fi
|
||||
}
|
||||
|
||||
% --- Importance title (left) and WHATs title (header band). ---
|
||||
\newcommand{\qfdDrawZoneTitles}{%
|
||||
\ifqfdshowimportance
|
||||
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
|
||||
@@ -251,6 +169,25 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
|
||||
}
|
||||
|
||||
% --- Title + concept subtitle, centred above the roof apex. ---
|
||||
\newcommand{\qfdDrawTitle}{%
|
||||
\ifqfdshowtitle
|
||||
\ifx\qfdProjectTitle\empty\else
|
||||
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
|
||||
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
|
||||
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2} % pre-computed (braces don't do arithmetic)
|
||||
\node[anchor=south, font=\large\bfseries, align=center]
|
||||
at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
|
||||
\ifx\qfdConcept\empty\else
|
||||
\node[anchor=north, font=\footnotesize\itshape, align=center,
|
||||
text width=\qfdSubW cm]
|
||||
at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
|
||||
\fi
|
||||
\fi
|
||||
\fi
|
||||
}
|
||||
|
||||
% --- Outer frames around each zone. ---
|
||||
\newcommand{\qfdDrawFrames}{%
|
||||
\begin{scope}[qfdmed]
|
||||
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
|
||||
@@ -265,6 +202,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\end{scope}
|
||||
}
|
||||
|
||||
% --- Legend on the right (Relations / Correlations / Evaluation). ---
|
||||
\newcommand{\qfdDrawLegend}{%
|
||||
\ifqfdshowlegend
|
||||
\pgfmathsetmacro{\qfdLegX}{%
|
||||
@@ -277,6 +215,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
|
||||
\draw[qfdmed, rounded corners=2pt]
|
||||
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
|
||||
% Relations
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
|
||||
{\qfdRelTitle};
|
||||
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
|
||||
@@ -286,6 +225,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
|
||||
\node[qfdweak] at (0.22, -1.4) {};
|
||||
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
|
||||
% Correlations (roof)
|
||||
\ifqfdshowroof \ifqfdshowcorrlegend
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
|
||||
{\qfdCorrTitle};
|
||||
@@ -295,6 +235,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
|
||||
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
|
||||
\fi \fi
|
||||
% Evaluation (3 alternatives)
|
||||
\ifqfdshowcompetitive \ifqfdshowevallegend
|
||||
\pgfmathsetmacro{\qfdEvalTop}{%
|
||||
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
|
||||
@@ -320,6 +261,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\fi
|
||||
}
|
||||
|
||||
% --- The environment users wrap their content in. ---
|
||||
\newenvironment{qfdhouse}{%
|
||||
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
|
||||
line cap=round, line join=round]
|
||||
@@ -335,18 +277,23 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
\qfdDrawRoof
|
||||
\qfdDrawScale
|
||||
\qfdDrawZoneTitles
|
||||
\qfdDrawTitle
|
||||
}{%
|
||||
\qfdDrawFrames
|
||||
\qfdDrawLegend
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{7}
|
||||
\def\qfdWhatW{4.6}
|
||||
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
|
||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||
\def\qfdImpTitle{Weight}
|
||||
\def\qfdProjectTitle{Macroplan}
|
||||
\def\qfdConcept{A \textbf{week-granular} plan view that holds every
|
||||
\textbf{Feature} honest against its \textbf{Original Estimate} and turns
|
||||
the slips into captured \textbf{Learnings}.}
|
||||
\qfdshowcompetitivefalse
|
||||
|
||||
\begin{document}
|
||||
@@ -409,7 +356,7 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
|
||||
% Basement: target / difficulty / abs / rel
|
||||
\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,
|
||||
3/{$\leq$1s}/2/82/13,
|
||||
4/{1 block}/2/82/13,
|
||||
@@ -427,37 +374,154 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
|
||||
Basement rows (top→bottom): **target · difficulty (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
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -1,6 +1,6 @@
|
||||
# Macroplan
|
||||
|
||||
A week-granular, **learning-oriented** view of the features a team has committed 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.
|
||||
|
||||
@@ -17,21 +17,22 @@ Dashboard ┣━━━━━◯ 🔴 n
|
||||
|
||||
## Status
|
||||
|
||||
Design is complete; **no code exists yet**. The next step is a thin vertical slice (TOML parse → Plan model → render one feature row).
|
||||
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, a **library** of named plans, the full week × feature grid render, derived on-time/late classification, milestones, and PNG + `.toml` export all work client-side.
|
||||
|
||||
## How it will work
|
||||
## How it works
|
||||
|
||||
- Author a plan as **TOML** in an in-app split editor (re-rendered on every keystroke).
|
||||
- The view is a **CSS-Grid** week × feature layout with the symbol vocabulary, real status colors (🟢/🟠/🔴 with hover notes), a "now" line, sticky feature-name and week-axis panes, and a trailing **Learning** column.
|
||||
- On-time vs. late is **derived** by the app against the Original Estimate — you never type "late".
|
||||
- **Milestones** are vertical lines tied to an explicit list of required features.
|
||||
- Keep a **library** of plans in localStorage; **export a PNG** to share into Slack or a deck.
|
||||
- Keep a **library** of named plans in localStorage and switch between them; **export** any plan as a `.toml` file, or the rendered view as a **PNG** to share into Slack or a deck.
|
||||
- Stack: Vite + Vue 3 + DaisyUI · `smol-toml` (parse) · `html-to-image` (export). Static SPA, no backend.
|
||||
|
||||
## Documentation
|
||||
|
||||
| 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 |
|
||||
| [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 |
|
||||
|
||||
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"]
|
||||
860
docs/superpowers/plans/2026-06-17-plans-library.md
Normal file
860
docs/superpowers/plans/2026-06-17-plans-library.md
Normal file
@@ -0,0 +1,860 @@
|
||||
# Plans Library Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the app hold several named Macroplans in localStorage — switch between them, create and delete them, and download any plan as a portable `.toml` file.
|
||||
|
||||
**Architecture:** `useMacroplan` becomes the owner of a `Library` (a `{version, activeId, plans[]}` object in localStorage), exposing the active plan's authoring state plus switch/create/delete operations. A new presentational `PlanSwitcher.vue` renders the header dropdown. A small `useSourceExport` util downloads the active plan's TOML, reusing a `slugify` helper extracted from the existing PNG exporter.
|
||||
|
||||
**Tech Stack:** Vue 3 (`<script setup lang="ts">`), TypeScript, DaisyUI 5, `smol-toml` (already wired via `parseMacroplan`), Vitest + `@vue/test-utils` + happy-dom.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-17-plans-library-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Vue 3 `<script setup lang="ts">` for all components; match the existing component style.
|
||||
- DaisyUI 5 classes for UI (the app pins a light theme); no new dependencies.
|
||||
- No backend — everything client-side (ADR-0002). localStorage keys: **`macroplan:library`** (new store) and **`macroplan:source`** (legacy, single-source — migrate away from it).
|
||||
- Vitest defaults to the `node` environment; any test that touches the DOM or `localStorage` MUST start with the line `// @vitest-environment happy-dom` (see `src/components/MacroplanGrid.test.ts`).
|
||||
- Plan ids come from `crypto.randomUUID()`. Target is modern browsers; no fallback.
|
||||
- A plan's display **name** is its parsed TOML `title` (cached as last-good); it is **not** a separately edited field.
|
||||
- **Invariant:** the library always holds ≥ 1 plan.
|
||||
- Conventional-commit messages, lowercase, imperative. **Do NOT add `Co-authored-by` lines** (user's standing rule).
|
||||
- Run a single test file with `pnpm exec vitest run <path>`; full suite with `pnpm test`; typecheck + build with `pnpm build`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TOML filename helpers + `downloadSource`
|
||||
|
||||
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`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/composables/useSourceExport.test.ts`:
|
||||
|
||||
```ts
|
||||
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")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm exec vitest run src/composables/useSourceExport.test.ts`
|
||||
Expected: FAIL — cannot resolve `./useSourceExport`.
|
||||
|
||||
- [ ] **Step 3: Extract `slugify` in `usePngExport.ts`**
|
||||
|
||||
Replace the existing `exportFilename` function (lines 5–13) with:
|
||||
|
||||
```ts
|
||||
/** 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, "")
|
||||
}
|
||||
|
||||
/** Slugified, stable download name derived from the plan title. */
|
||||
export function exportFilename(title: string): string {
|
||||
return `macroplan-${slugify(title) || "plan"}.png`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `useSourceExport.ts`**
|
||||
|
||||
```ts
|
||||
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`
|
||||
}
|
||||
|
||||
/** 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 url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run both filename test files to verify they pass**
|
||||
|
||||
Run: `pnpm exec vitest run src/composables/useSourceExport.test.ts src/composables/usePngExport.test.ts`
|
||||
Expected: PASS (both files; the existing `exportFilename` tests still pass because output is unchanged).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/composables/usePngExport.ts src/composables/useSourceExport.ts src/composables/useSourceExport.test.ts
|
||||
git commit -m "feat(export): add .toml source download and shared slugify helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `PlanSwitcher.vue` (presentational dropdown)
|
||||
|
||||
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`
|
||||
- Emits: `select: [id: string]`, `new: []`, `download: [id: string]`
|
||||
- DOM contract relied on by the test: each plan is an `<a>` inside a `<li>`; the active plan's `<a>` has class `active`; the last two `<a>` items are **New plan** then **Download .toml**.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
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"
|
||||
|
||||
const plans = [
|
||||
{ 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")
|
||||
})
|
||||
|
||||
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"])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm exec vitest run src/components/PlanSwitcher.test.ts`
|
||||
Expected: FAIL — cannot resolve `./PlanSwitcher.vue`.
|
||||
|
||||
- [ ] **Step 3: Create `PlanSwitcher.vue`**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
plans: { id: string; name: string }[]
|
||||
activeId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
new: []
|
||||
download: [id: string]
|
||||
}>()
|
||||
|
||||
const activeName = computed(
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1 normal-case">
|
||||
<span class="max-w-[14rem] truncate font-semibold">{{ activeName }}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content z-50 mt-1 w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li v-for="p in plans" :key="p.id">
|
||||
<a :class="{ active: p.id === activeId }" @click="emit('select', p.id)">
|
||||
<span class="truncate">{{ p.name }}</span>
|
||||
<span v-if="p.id === activeId" aria-hidden="true">✓</span>
|
||||
</a>
|
||||
</li>
|
||||
<div class="my-1 border-t border-base-200"></div>
|
||||
<li><a @click="emit('new')">+ New plan</a></li>
|
||||
<li><a @click="emit('download', activeId)">⤓ Download .toml</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm exec vitest run src/components/PlanSwitcher.test.ts`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/PlanSwitcher.vue src/components/PlanSwitcher.test.ts
|
||||
git commit -m "feat(ui): add the plan switcher dropdown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `useMacroplan` library refactor
|
||||
|
||||
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
|
||||
- `plan: ComputedRef<Plan | null>`
|
||||
- `error: ComputedRef<string | null>`
|
||||
- `plans: ComputedRef<{ id: string; name: string }[]>`
|
||||
- `activeId: ComputedRef<string>`
|
||||
- `selectPlan(id: string): void`
|
||||
- `newPlan(): void`
|
||||
- `deletePlan(id: string): void`
|
||||
- `resetToSample(): void` _(temporary — removed in Task 4)_
|
||||
|
||||
- [ ] **Step 1: Write the failing tests (storage, binding, CRUD)**
|
||||
|
||||
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"
|
||||
|
||||
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", () => {
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(m.plans.value[0].id)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
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", () => {
|
||||
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(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")
|
||||
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", () => {
|
||||
localStorage.setItem(
|
||||
LIB_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
describe("useMacroplan — CRUD", () => {
|
||||
it("newPlan appends a sample plan and activates it", () => {
|
||||
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(SAMPLE_PLAN)
|
||||
})
|
||||
|
||||
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
|
||||
m.deletePlan(second.id)
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(first.id)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const m = useMacroplan()
|
||||
const aId = m.activeId.value
|
||||
m.newPlan()
|
||||
const bId = m.activeId.value
|
||||
m.source.value = 'title = "B"\n[[feature]]\n' // B broken
|
||||
await nextTick()
|
||||
m.selectPlan(aId)
|
||||
await nextTick()
|
||||
expect(m.plan.value).toBeTruthy() // A renders
|
||||
m.selectPlan(bId)
|
||||
await nextTick()
|
||||
expect(m.error.value).toBeTruthy() // B's parse error surfaces
|
||||
expect(m.plan.value).toBeNull() // no stale A render leaks through
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `pnpm exec vitest run src/composables/useMacroplan.test.ts`
|
||||
Expected: FAIL — the new API (`plans`, `activeId`, `newPlan`, etc.) does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Rewrite `useMacroplan.ts`**
|
||||
|
||||
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"
|
||||
|
||||
const STORAGE_KEY = "macroplan:library"
|
||||
const LEGACY_KEY = "macroplan:source"
|
||||
|
||||
export interface StoredPlan {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
}
|
||||
|
||||
interface Library {
|
||||
version: 1
|
||||
activeId: string
|
||||
plans: StoredPlan[]
|
||||
}
|
||||
|
||||
/** The source's title if it fully parses, else null (so the cached name only
|
||||
* ever updates on a valid parse). */
|
||||
function titleOf(source: string): string | null {
|
||||
try {
|
||||
return parseMacroplan(source).title
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function newStoredPlan(source: string): StoredPlan {
|
||||
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
|
||||
}
|
||||
|
||||
function save(lib: Library): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(lib))
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the initial library: existing store → legacy migration → fresh seed.
|
||||
* Always persisted before returning so a migrated/seeded library survives a
|
||||
* reload even if the user never edits. */
|
||||
function loadLibrary(): Library {
|
||||
let result: Library | null = null
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const lib = JSON.parse(raw) as Library
|
||||
if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) {
|
||||
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
|
||||
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
|
||||
}
|
||||
} catch {
|
||||
/* corrupt JSON → fall through to migration / seed */
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const legacy = localStorage.getItem(LEGACY_KEY)
|
||||
if (legacy != null) {
|
||||
const p = newStoredPlan(legacy)
|
||||
localStorage.removeItem(LEGACY_KEY)
|
||||
result = { version: 1, activeId: p.id, plans: [p] }
|
||||
} else {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
result = { version: 1, activeId: seed.id, plans: [seed] }
|
||||
}
|
||||
}
|
||||
|
||||
save(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Library — the collection of named Macroplans in localStorage
|
||||
* (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
|
||||
* error, with the last good render kept through transient typos, F3) plus the
|
||||
* switch / create / delete operations over the library.
|
||||
*/
|
||||
export function useMacroplan() {
|
||||
const lib = ref<Library>(loadLibrary())
|
||||
const lastGood = ref<Plan | null>(null)
|
||||
|
||||
const active = computed<StoredPlan>(
|
||||
() => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0],
|
||||
)
|
||||
|
||||
const source = computed<string>({
|
||||
get: () => active.value.source,
|
||||
set: (v) => {
|
||||
const p = active.value
|
||||
p.source = v
|
||||
const t = titleOf(v)
|
||||
if (t) p.name = t // refresh the cached label only on a valid parse
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
|
||||
try {
|
||||
return { plan: buildPlan(parseMacroplan(source.value)), error: null }
|
||||
} catch (e) {
|
||||
return { plan: null, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
parsed,
|
||||
(p) => {
|
||||
if (p.plan) lastGood.value = p.plan
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Persist the whole library on any change (best-effort, like the old autosave).
|
||||
watch(lib, (l) => save(l), { deep: true })
|
||||
|
||||
// Switching plans drops the prior render so a broken target shows its own
|
||||
// error/empty state, not the previous plan's grid.
|
||||
function switchTo(id: string): void {
|
||||
lib.value.activeId = id
|
||||
lastGood.value = parsed.value.plan
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
||||
error: computed(() => parsed.value.error),
|
||||
plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
|
||||
activeId: computed(() => lib.value.activeId),
|
||||
selectPlan: (id: string) => {
|
||||
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
|
||||
},
|
||||
newPlan: () => {
|
||||
const p = newStoredPlan(SAMPLE_PLAN)
|
||||
lib.value.plans.push(p)
|
||||
switchTo(p.id)
|
||||
},
|
||||
deletePlan: (id: string) => {
|
||||
const idx = lib.value.plans.findIndex((p) => p.id === id)
|
||||
if (idx === -1) return
|
||||
const wasActive = lib.value.activeId === id
|
||||
lib.value.plans.splice(idx, 1)
|
||||
if (lib.value.plans.length === 0) {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
lib.value.plans.push(seed)
|
||||
switchTo(seed.id)
|
||||
} else if (wasActive) {
|
||||
switchTo(lib.value.plans[Math.max(0, idx - 1)].id)
|
||||
}
|
||||
},
|
||||
// TODO(task 4): remove once App.vue drops the "Reset to sample" button.
|
||||
resetToSample: () => {
|
||||
source.value = SAMPLE_PLAN
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `pnpm exec vitest run src/composables/useMacroplan.test.ts`
|
||||
Expected: PASS (all groups).
|
||||
|
||||
- [ ] **Step 5: Confirm the app still typechecks (App.vue unchanged, `resetToSample` retained)**
|
||||
|
||||
Run: `pnpm build`
|
||||
Expected: build succeeds (no type errors).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/composables/useMacroplan.ts src/composables/useMacroplan.test.ts
|
||||
git commit -m "feat(model): make useMacroplan own a library of named plans"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire `App.vue` (switcher, delete confirm, .toml download)
|
||||
|
||||
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`**
|
||||
|
||||
Delete these lines from the returned object:
|
||||
|
||||
```ts
|
||||
// TODO(task 4): remove once App.vue drops the "Reset to sample" button.
|
||||
resetToSample: () => {
|
||||
source.value = SAMPLE_PLAN
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite `App.vue`**
|
||||
|
||||
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"
|
||||
|
||||
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
|
||||
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
||||
|
||||
const exportRoot = ref<HTMLElement>()
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
const activeName = computed(
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
|
||||
)
|
||||
|
||||
function downloadToml() {
|
||||
downloadSource(source.value, sourceFilename(plan.value?.title ?? activeName.value))
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
deletePlan(activeId.value)
|
||||
confirmingDelete.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col bg-base-200 font-sans text-base-content">
|
||||
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||
<img src="/favicon.svg" alt="" class="size-6" />
|
||||
<div class="flex-1">
|
||||
<PlanSwitcher
|
||||
:plans="plans"
|
||||
:active-id="activeId"
|
||||
@select="selectPlan"
|
||||
@new="newPlan"
|
||||
@download="downloadToml"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="!plan || busy"
|
||||
title="Copy the rendered plan to the clipboard as a PNG"
|
||||
@click="copyPng(exportRoot)"
|
||||
>
|
||||
<span v-if="busy" class="loading loading-spinner loading-xs"></span>
|
||||
Copy PNG
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="!plan || busy"
|
||||
title="Download the rendered plan as a PNG"
|
||||
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
title="Delete this plan"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<section
|
||||
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r"
|
||||
>
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Source (TOML)
|
||||
</div>
|
||||
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
|
||||
</section>
|
||||
|
||||
<section class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="plan" ref="exportRoot" class="export-root">
|
||||
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
|
||||
<MacroplanGrid :plan="plan" />
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/60">
|
||||
Nothing to render yet — fix the source on the left.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': confirmingDelete }">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-base font-semibold">Delete “{{ activeName }}”?</h3>
|
||||
<p class="py-2 text-sm text-base-content/70">
|
||||
This removes the plan from your library. Download its .toml first if you want to keep it.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-sm" @click="confirmingDelete = false">Cancel</button>
|
||||
<button class="btn btn-sm btn-error" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="confirmingDelete = false">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div v-if="toast" class="toast toast-end">
|
||||
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
|
||||
<span>{{ toast.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* While exporting, grow to the timeline's full width and un-clip the grid so the
|
||||
captured PNG shows every week column (not just the on-screen scroll window).
|
||||
The base-200 frame + padding give the snapshot a little breathing room. */
|
||||
.export-root.exporting {
|
||||
width: max-content;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
.export-root.exporting :deep(.macroplan) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + build**
|
||||
|
||||
Run: `pnpm build`
|
||||
Expected: build succeeds — no reference to `resetToSample` remains, all props/events typecheck.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: PASS — all suites (existing + the new filename, switcher, and library suites).
|
||||
|
||||
- [ ] **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.
|
||||
4. Switch back to the first plan via the dropdown → its source returns.
|
||||
5. **⤓ Download .toml** downloads a `macroplan-<slug>.toml` file containing the current source.
|
||||
6. The 🗑 button opens a confirm modal naming the active plan; **Cancel** dismisses, **Delete** removes it and switches to the preceding plan.
|
||||
7. Delete every plan in turn → the last delete leaves a fresh sample plan (never an empty app).
|
||||
8. Reload the page → the library and active plan persist.
|
||||
|
||||
Expected: all eight behave as described.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/App.vue src/composables/useMacroplan.ts
|
||||
git commit -m "feat(ui): wire the plan switcher, delete confirm and .toml download into the app"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Documentation
|
||||
|
||||
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`
|
||||
|
||||
- [ ] **Step 1: Add the `Library` term to `CONTEXT.md`**
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a Library relationship line to `CONTEXT.md`**
|
||||
|
||||
In `## Relationships`, after the first bullet (the one starting `A **Macroplan** contains a flat, author-ordered list...`), add:
|
||||
|
||||
```markdown
|
||||
- The **Library** holds many **Macroplans**, exactly one of which is active (shown in the editor and grid). Each is identified internally by a stable id and labelled by its **title**.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the README Status paragraph**
|
||||
|
||||
Replace:
|
||||
|
||||
```markdown
|
||||
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, the full week × feature grid render, derived on-time/late classification, milestones, and PNG export all work client-side.
|
||||
|
||||
Not yet built: a **library** of multiple named plans. Today a single source autosaves to localStorage.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
**Feature-complete** against the [design](DESIGN.md) and covered by tests — TOML authoring with live reload, a **library** of named plans, the full week × feature grid render, derived on-time/late classification, milestones, and PNG + `.toml` export all work client-side.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the README "How it works" persistence bullet**
|
||||
|
||||
Replace:
|
||||
|
||||
```markdown
|
||||
- Your source **autosaves to localStorage**; **export a PNG** to share into Slack or a deck.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
- Keep a **library** of named plans in localStorage and switch between them; **export** any plan as a `.toml` file, or the rendered view as a **PNG** to share into Slack or a deck.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the docs build is unaffected and commit**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: PASS (docs-only change; suites still green).
|
||||
|
||||
```bash
|
||||
git add CONTEXT.md README.md
|
||||
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.
|
||||
- Delete active plan with confirm → Task 3 (`deletePlan`) + Task 4 (modal).
|
||||
- `.toml` download → Task 1 (`sourceFilename`/`downloadSource`) + Task 4.
|
||||
- Migration from legacy key + always-≥1 invariant + corrupt fallback + stale-activeId repair → Task 3 (tested).
|
||||
- Name = cached last-good title → Task 3 (`titleOf` in the `source` setter, tested).
|
||||
- `slugify` extracted, `exportFilename` output unchanged → Task 1 (existing test still passes).
|
||||
- Switch resets last-good render → Task 3 (`switchTo`, tested).
|
||||
- `Library` glossary term + README flip → Task 5.
|
||||
- All spec requirements map to a task. No gaps.
|
||||
|
||||
**2. Placeholder scan:** No TBD/TODO-as-deliverable, no "add error handling" hand-waves; every code step shows complete code. The one `TODO(task 4)` comment is a deliberate, scheduled removal (Task 4, Step 1), not an unfinished placeholder.
|
||||
|
||||
**3. Type consistency:** `slugify` (Task 1) is consumed by `sourceFilename` (Task 1) and unchanged `exportFilename`. `useMacroplan`'s return shape (Task 3) — `source`, `plan`, `error`, `plans`, `activeId`, `selectPlan`, `newPlan`, `deletePlan` — matches exactly what `App.vue` destructures (Task 4). `PlanSwitcher` props (`plans: {id,name}[]`, `activeId: string`) and emits (`select`, `new`, `download`) match the App template bindings. `StoredPlan`/`Library` are internal to `useMacroplan`. Consistent throughout.
|
||||
163
docs/superpowers/specs/2026-06-17-plans-library-design.md
Normal file
163
docs/superpowers/specs/2026-06-17-plans-library-design.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Plans Library — Design
|
||||
|
||||
**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."_
|
||||
|
||||
## Problem
|
||||
|
||||
The app stores exactly one Macroplan, autosaved to a single localStorage key (`macroplan:source`). ADR-0002 always described localStorage as **a library of named Macroplans**, but only the single-plan slice was built. The README advertised a "library of plans" the app never had.
|
||||
|
||||
This adds the library: keep several named Macroplans, switch between them, create and delete them, and download any plan as a portable `.toml` file. The `.toml` download is in scope because ADR-0002 makes export the durability mechanism ("durability rests on Export"); a library of N plans in localStorage with no file export would multiply un-backupable state.
|
||||
|
||||
## 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).
|
||||
- Delete the active plan (with a confirm).
|
||||
- Download the active plan's source as `<title-slug>.toml`.
|
||||
- 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.
|
||||
- Reordering, folders, search, tags.
|
||||
- Any backend or multi-device sync (explicitly rejected by ADR-0002).
|
||||
|
||||
## Vocabulary
|
||||
|
||||
A new term enters the [ubiquitous language](../../../CONTEXT.md):
|
||||
|
||||
> **Library** — the collection of saved **Macroplans** held in the browser's localStorage. The live store; durability rests on exporting a plan's `.toml` (per ADR-0002). Not a Macroplan itself, and carries no status of its own.
|
||||
|
||||
A plan's **display name** is its TOML `title` (the existing concept), not a separate field — see Data Model.
|
||||
|
||||
## Data Model
|
||||
|
||||
One new localStorage key **replaces** `macroplan:source`:
|
||||
|
||||
```
|
||||
macroplan:library → {
|
||||
version: 1,
|
||||
activeId: string,
|
||||
plans: [ { id: string, name: string, source: string } ]
|
||||
}
|
||||
```
|
||||
|
||||
| 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.
|
||||
|
||||
### Invariant
|
||||
|
||||
**The library always holds ≥ 1 plan.** Deleting the last plan creates a fresh sample plan rather than leaving an empty store. This keeps `activeId` always valid and the editor always bound to something.
|
||||
|
||||
### Migration
|
||||
|
||||
On load, `useMacroplan` resolves the initial library:
|
||||
|
||||
1. If `macroplan:library` exists and parses → use it.
|
||||
2. Else if the legacy `macroplan:source` exists → wrap that source as a single plan (`name` = its parsed title, or `"Untitled"` if it doesn't parse), mark it active, write `macroplan:library`, and **remove** the legacy key.
|
||||
3. Else → seed one sample plan, active.
|
||||
|
||||
A corrupt/unparseable `macroplan:library` value is treated as case 3 (seed fresh) rather than throwing — autosave is best-effort, consistent with the existing try/catch around `localStorage.setItem`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### `useMacroplan` (refactor) — the library owner
|
||||
|
||||
Holds `plans` and `activeId`; persists the whole library to `macroplan:library` on any change (best-effort, in a try/catch as today).
|
||||
|
||||
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. |
|
||||
|
||||
`resetToSample` is **removed** — `newPlan()` (a blank page) replaces it.
|
||||
|
||||
### `PlanSwitcher.vue` (new) — presentational
|
||||
|
||||
- **Props:** `plans: { id: string; name: string }[]`, `activeId: string`.
|
||||
- **Emits:** `select(id)`, `new`, `delete(id)`, `download(id)`.
|
||||
- Renders a DaisyUI dropdown: the active plan's name as the trigger; a list of plans (active one checked); then `+ New plan` and `⤓ Download .toml` entries.
|
||||
- No storage or parsing logic — it only reflects props and emits intent.
|
||||
|
||||
### `downloadSource` + `slugify` (new util) — `.toml` export
|
||||
|
||||
- Refactor `exportFilename` (in `usePngExport.ts`) to build on an extracted `slugify(title): string` helper, so PNG and `.toml` names stay consistent:
|
||||
- `exportFilename(title)` → `macroplan-<slug>.png` (unchanged output; existing test still passes).
|
||||
- `sourceFilename(title)` → `macroplan-<slug>.toml`.
|
||||
- `downloadSource(source: string, filename: string)`: `Blob(['…'], { type: 'text/plain' })` → object URL → anchor click → revoke. Mirrors the existing `downloadPng` mechanics. Lives in a small util (e.g. `src/composables/useSourceExport.ts`) — kept separate from `usePngExport` since it's source export, not render export.
|
||||
|
||||
### App shell (`App.vue`)
|
||||
|
||||
- The static `<h1>Macroplan</h1>` + subtitle block in the header is **replaced** by `<PlanSwitcher>` (logo kept).
|
||||
- A **trash** button is added to the top bar; it deletes the **active** plan behind a confirm modal (`Delete "<name>"?` · Cancel / Delete). DaisyUI modal or `<dialog>`.
|
||||
- `Copy PNG` and `Download` (PNG) buttons are **unchanged** — `.toml` download lives in the switcher dropdown precisely so there is no competing "Download" label in the top bar.
|
||||
- Wire `PlanSwitcher` events: `select → selectPlan`, `new → newPlan`, `delete → open confirm → deletePlan`, `download → downloadSource(source, sourceFilename(plan.title))`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
edit TOML ─► source (active plan) ─► autosave whole library ─► localStorage
|
||||
│
|
||||
└─► parse ─► plan/error ─► refresh active plan's cached name (on valid title)
|
||||
|
||||
switch ─► selectPlan(id) ─► activeId changes ─► source rebinds, lastGood reset ─► re-parse ─► render
|
||||
new ─► newPlan() ─► append blank plan ─► selectPlan(newId)
|
||||
delete ─► confirm ─► deletePlan(activeId) ─► re-point active to preceding plan (or re-seed if last) ─► render
|
||||
toml ─► downloadSource(source, sourceFilename(title)) ─► browser download
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **localStorage write fails** (full/blocked): swallowed in try/catch; the in-memory library stays usable for the session — same posture as today's autosave.
|
||||
- **Corrupt `macroplan:library` on read:** fall back to seeding a fresh sample (don't throw).
|
||||
- **Active plan source unparseable:** the grid keeps the last-good render and shows the error in the editor — existing behavior, now per active plan.
|
||||
- **`crypto.randomUUID` unavailable:** target is modern browsers (the app already uses Clipboard API, `color-mix`, dynamic import); no fallback needed. Note in the plan if a wider support floor is required.
|
||||
|
||||
## 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).
|
||||
- `newPlan()` appends a **blank** plan and makes it active (the sample only seeds an empty library).
|
||||
- `deletePlan(active)` removes it and re-points `activeId` to a remaining plan.
|
||||
- `deletePlan` of the **last** plan re-seeds a fresh sample (invariant holds).
|
||||
- Editing `source` updates the active entry and refreshes its cached `name` on a valid title; a broken edit keeps the last-good `name` and render.
|
||||
- `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.
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. Possible future follow-ups (out of scope): `.toml` file-open import, duplicate-plan, and reordering.
|
||||
12
package.json
12
package.json
@@ -1,18 +1,24 @@
|
||||
{
|
||||
"name": "macroplan",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^5.5.23",
|
||||
"html-to-image": "^1.11.13",
|
||||
"shiki": "^4.2.0",
|
||||
"smol-toml": "^1.6.1",
|
||||
"valibot": "^1.4.1",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22,6 +28,8 @@
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"happy-dom": "^20.10.4",
|
||||
"oxfmt": "^0.55.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
|
||||
465
pnpm-lock.yaml
generated
465
pnpm-lock.yaml
generated
@@ -11,12 +11,18 @@ importers:
|
||||
daisyui:
|
||||
specifier: ^5.5.23
|
||||
version: 5.5.23
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
shiki:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
smol-toml:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
valibot:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1(typescript@6.0.3)
|
||||
vue:
|
||||
specifier: ^3.5.34
|
||||
version: 3.5.38(typescript@6.0.3)
|
||||
@@ -39,6 +45,12 @@ importers:
|
||||
happy-dom:
|
||||
specifier: ^20.10.4
|
||||
version: 20.10.4
|
||||
oxfmt:
|
||||
specifier: ^0.55.0
|
||||
version: 0.55.0
|
||||
oxlint:
|
||||
specifier: ^1.70.0
|
||||
version: 1.70.0
|
||||
tailwindcss:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
@@ -115,6 +127,250 @@ packages:
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.55.0':
|
||||
resolution: {integrity: sha512-+rFDOqQe5LOWgxrAJaZgLRudr6GQm0wGI6gtu7vVkrdLGjNMUSGbAlaCr8j7F2H2Er97vYQCU8WDb30onqMM1g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.55.0':
|
||||
resolution: {integrity: sha512-ctulLq8s3x8Zmvw6+iccB09TIKERAklRSmbJ10gk8mlAn05qZxoyo52dj3Hi9IJcmDSwF54fQaTVh2CbL6PInw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.55.0':
|
||||
resolution: {integrity: sha512-xDQczLH9pw/RBk1h/GH0qcGMm8hQtmtVHBNLSH3lk1gEIR09hZ4L+mJQl4VqiVAvPK9VG9PYrWWuSQLt7xTbiA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.55.0':
|
||||
resolution: {integrity: sha512-JaNoFCkF2CJdGgpPSMbuO9HVyXyoNGIhMHPvp6NYAjeVKw9XEYc0HcUWJLPQa3Q69WV5wMa9m5jPMJPtbLtcRg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.55.0':
|
||||
resolution: {integrity: sha512-DNbszhpg6S2MIzax5azdHFTTBIVkR5xr8yyRZuA4yoDAwOkzIp3tmldgKZM2+VlT+hJIG0xUksA+elISzMEAfA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
|
||||
resolution: {integrity: sha512-2snoaoRfFFyGnbOcKUK36rREBYxe/Xgz3uHbiA5zbCB/s6R4DQj4mHqYAaWWhgizCUSDxV8cE9zAZ0XleNpKGw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
|
||||
resolution: {integrity: sha512-q1aktHF/WRpSK81BX1dE/9vWrS2jGw1Nax2kb4DBLGAewubCLcoNyp4Zl/NSMgbv3vUS46Z33wIQkBVYOP3PYg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
|
||||
resolution: {integrity: sha512-VD0y36aENezl/3tsclA/4G53Cc7iV+7Uoh7gz4yvcOTaEYBtJpQsE6PKDGTtUtOvGS4kv51ybfXY/nWZejO5IA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.55.0':
|
||||
resolution: {integrity: sha512-r8xlKJFcsRmn0H5jZrdORae6RX9jDBrZVvOoxF+bCQtampQJClv80aZEHsv+NsLsp2KCE5ql79O7DpPVzYWpXA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
|
||||
resolution: {integrity: sha512-GRKv/HXHcwIVld/WU61rF0g0R16hl5EJ+ScKdpjevT57lnLnagj/U2YUbXf2mT+2Pg1uCzWC+mvGicPV3CDdLQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
|
||||
resolution: {integrity: sha512-rdv57enTiPtpSYRMKfAiEbQb0Puw5t9N7isVinDoo5qeLDScro2gznmZqSgSWbVZRzLisTeCTW8Qwgw0bOHv3A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
|
||||
resolution: {integrity: sha512-7v1nNrlD43VY6+sYQ6efYyb3lE6QY182304PD/768ZxTjOmFd/3dQa3u/nGBUAXYdGSWOQc5N3PnS0QzUXyEIA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
|
||||
resolution: {integrity: sha512-f4lJLUSPOgScjFl9LiflKCTocyNRwE25JmTMbN4XQdDjoZzEHjqf3wA3VESF1/csg7i8m7+EQLbrZyYDqe10UQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.55.0':
|
||||
resolution: {integrity: sha512-MihqiPziJNoWy4MqNSV+jVA1g+07iQDjZiR0vaCaDoPgFEiJpCMsxamktzLV07cEeQsSJ04vQaU4CzCQwIvtDA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.55.0':
|
||||
resolution: {integrity: sha512-Yqghym7KYAVjP9MmSrNZiDeerMuoejNjo0r3ox5H3GDKk8eAfl8VyJm9i+pWCLDCTnAbcTUMMN2ZKjUYXH1v3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.55.0':
|
||||
resolution: {integrity: sha512-s5SDvVVSbyQl1V5UU3Yl12M+XLUQ3rl5SglNqgAA2K4PXUtQhyNSS00wivONPEnNo5W01rCou8WkDNyvI/RGHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
|
||||
resolution: {integrity: sha512-7p9FB5R32tw2KyyNX3wpQrR2WHwEHvMEiBlGXxeTCaRMCVNx3UtFMAUbaQ/pRNWIrEUZmYhJ6tcUH52uPTRYjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
|
||||
resolution: {integrity: sha512-ZYqj3fDnOT1IaVGMP5kpmkQl4F3tQIm2ZyAxvqkJYmI0xgWWak4ss4XYwv3VDfM+TWXeC9K4uQ/wW5jm/5XABA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.55.0':
|
||||
resolution: {integrity: sha512-eEYT5tivGnGbPHuOHuQpi6CGLObhh0re/5jcNQHihD2GRYkTM85dyi5a19zjP8Q00t1uqAx+/QGLUGdHeqzWyg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.70.0':
|
||||
resolution: {integrity: sha512-zFh0P4cswmRvw6nkyb89dr18rRanuaCPAsEXsFDoQY8WdaquI8Pt4NWFjaMJg6L23cy5NeN8J9cBnREbWzZhaw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.70.0':
|
||||
resolution: {integrity: sha512-qI8o4HZjeGiBrWv+pJv4lH0Yi2Gl/JSp/EumBUApezJprIKa5PS4nU0lQsQngtky8k+SplQIOjv6hwu0SSxeyg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.70.0':
|
||||
resolution: {integrity: sha512-8KjgVVHI5F9nVwHCRwwA78Ty7zNKP4Wd9OeN5PSv3iu/F/u1RVXoOCgLhWqust6HmwQG6xc8c+RCyaWENy24+w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.70.0':
|
||||
resolution: {integrity: sha512-WVydssv5PSUBXFJTdNBWlmGkbNmvPGaFt/2SUT/EZRB6bq6bEOHmMlbnupZD5jmlEvi9+mZJHi8TCw15lyfSfQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.70.0':
|
||||
resolution: {integrity: sha512-hJucmUf8OlinHNb1R7fI4Fw6WsAstOz7i8nmkWQfiHoZXtbufNm+MxiDTIMk1ggh2Ro4vLzgQ+bKvRY54MZoRA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
|
||||
resolution: {integrity: sha512-1BnS7wbCYDSXwWzJJ+mc3NURoha6m6m6RT5c6vgAY3oz7C3OVXP+S0awo2mRq97arrJkVvO3qRQfyAHL+76xtQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
|
||||
resolution: {integrity: sha512-yKy/UdbR55+M2yEcuiV5DCNC/gdQAjr/GioUy50QwBzSrKm8ueWADqyRLS9Xk+qjNeCYGg6A8FvUBds56ttfqg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.70.0':
|
||||
resolution: {integrity: sha512-0A5XJ4alvmqFUFP/4oYSyaO+qLto/HrKEWTSaegiVl+HOufFngK2BjYw9x4RbwBt/du5QG6l5q1zeWiJYYG5yg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.70.0':
|
||||
resolution: {integrity: sha512-JiylyurlB0CLSedNtx1gzv3FvfWPF1h/2Y3BJszPLNt5XQFlBsH5ke0Jle3iJb3uqu5m2e7A/DwzpuCAHdiU+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
|
||||
resolution: {integrity: sha512-J8VPG7I3/HmgaU4u8pNU2kFx2+0U+vPLS1dXFxXOaR/2TQ0f8AC7DRz0SRGRI1bfphnX2hVYTTtLuhL4nYKL+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
|
||||
resolution: {integrity: sha512-N2+4lV2KLN+oXTIIIwmWDhwkrnvqf5oX7Hw0zPjk+RuIVgiBQSOlJWF7uQoFx2siEYX0ZQ5cfSbEAHm+J3t7Wg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.70.0':
|
||||
resolution: {integrity: sha512-1e2L7cFCvx9QDzq6NPP+0tABKb5z6nWHyddWTNKprEsjO9xNrAtPowuCGpjNXxkTdsMiZ4jc8YQ5SstZd4XK6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.70.0':
|
||||
resolution: {integrity: sha512-Kwu/l/8GcYibCWA9m9N5pRXMIKVSsL/YbgpLzYkqDhWTiqdRfnNJ/+nqIKRKQiFbHWsdlHEhzMwruJK+qcEruA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.70.0':
|
||||
resolution: {integrity: sha512-tap04CsHYOl0nSAQJfPNIuBxqEPB2HnhQqwaOXLg1jnp2XfRo8Fa814dA4QC4zpvTWXCjAAaCY1W5LOORkEQuQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.70.0':
|
||||
resolution: {integrity: sha512-hzJa/WgvtJpbBD9rgfy0qe+MjbxOXNUT0bfR1S6EQQzfTtBFA9xg5q8KSwRrQ2QfSS+TaP4j+4mVPQrfNc6UNg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.70.0':
|
||||
resolution: {integrity: sha512-xbsaNSNzVSnaJACCUYr1HQMyY/Q/Q1LkePmHG3UvZPvGCYGNxrsZp9OmtA6ick8xH47ltRRbRrPCM1YXYcyC+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.70.0':
|
||||
resolution: {integrity: sha512-icAEsUI7JbW1TMRdEXV83mVAInhRVQYuuAlPpxdGwJ95chNdnCzjloRW8GglT0WvzOEZSio6fnYSk2DJ2Hv7LQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.70.0':
|
||||
resolution: {integrity: sha512-FHMSWbVsPVs/f+Jcl04ws4JJ2wUnauyTzlpxWRG/lSO/8GpX08Fo2gQZqdA6CrRFI+zvkxl+N/KwJGWfUwYVZA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.70.0':
|
||||
resolution: {integrity: sha512-ptOlKwCz7n4AKs5VweMqG6DAg677FmKOK+vBkkL9DMNgFATIQ+upqUYBTOEwRQyRAx1ncGlPlXleV2hIcm3z4g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -638,6 +894,9 @@ packages:
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -795,6 +1054,32 @@ packages:
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
oxfmt@0.55.0:
|
||||
resolution: {integrity: sha512-jSj2wCTakwgPMxkfiVZX0jf+nX+Nz6xlyAZjqNE0qXTFdCBPYlP6JAN+ODjmealw7DXBjOzYbdsqwBMAZnPZ6A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
svelte:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
oxlint@1.70.0:
|
||||
resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.22.1'
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@@ -921,6 +1206,10 @@ packages:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinypool@2.1.0:
|
||||
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -954,6 +1243,14 @@ packages:
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
valibot@1.4.1:
|
||||
resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
@@ -1171,6 +1468,120 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.55.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.70.0':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -1661,6 +2072,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
@@ -1795,6 +2208,52 @@ snapshots:
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
oxfmt@0.55.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.55.0
|
||||
'@oxfmt/binding-android-arm64': 0.55.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.55.0
|
||||
'@oxfmt/binding-darwin-x64': 0.55.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.55.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.55.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.55.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.55.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.55.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.55.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.55.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.55.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.55.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.55.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.55.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.55.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.55.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.55.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.55.0
|
||||
|
||||
oxlint@1.70.0:
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.70.0
|
||||
'@oxlint/binding-android-arm64': 1.70.0
|
||||
'@oxlint/binding-darwin-arm64': 1.70.0
|
||||
'@oxlint/binding-darwin-x64': 1.70.0
|
||||
'@oxlint/binding-freebsd-x64': 1.70.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.70.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.70.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.70.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.70.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.70.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.70.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.70.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.70.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.70.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.70.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.70.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.70.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.70.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.70.0
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
@@ -1924,6 +2383,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
trim-lines@3.0.1: {}
|
||||
@@ -1958,6 +2419,10 @@ snapshots:
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
valibot@1.4.1(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
vfile-message@4.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/App.test.ts
Normal file
89
src/App.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment happy-dom
|
||||
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")>()
|
||||
return { ...actual, downloadSource: vi.fn() }
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
function mountApp() {
|
||||
return mount(App, { global: { stubs } })
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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 nextTick()
|
||||
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download
|
||||
})
|
||||
|
||||
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 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(".modal-action .btn-error").trigger("click") // Delete
|
||||
await nextTick()
|
||||
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 () => {
|
||||
const w = mountApp()
|
||||
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)
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched
|
||||
})
|
||||
|
||||
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 nextTick()
|
||||
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded
|
||||
expect(w.text()).toContain("Q3 — Checkout revamp")
|
||||
})
|
||||
|
||||
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)
|
||||
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")
|
||||
})
|
||||
})
|
||||
179
src/App.vue
179
src/App.vue
@@ -1,9 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { useMacroplan } from './composables/useMacroplan'
|
||||
import PlanEditor from './components/PlanEditor.vue'
|
||||
import MacroplanGrid from './components/MacroplanGrid.vue'
|
||||
import { ref, computed, watch } 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, resetToSample } = useMacroplan()
|
||||
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
|
||||
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
||||
|
||||
const exportRoot = ref<HTMLElement>()
|
||||
const confirmingDelete = ref(false)
|
||||
|
||||
// Which panes are visible: the Source editor, the rendered Macroplan, or both.
|
||||
// Kept in the URL (?view=…) so a layout is shareable/bookmarkable, not persisted.
|
||||
type ViewMode = "source" | "split" | "macroplan"
|
||||
|
||||
function readViewMode(): ViewMode {
|
||||
const v = new URLSearchParams(location.search).get("view")
|
||||
return v === "source" || v === "macroplan" ? v : "split"
|
||||
}
|
||||
|
||||
const mode = ref<ViewMode>(readViewMode())
|
||||
const showSource = computed(() => mode.value !== "macroplan")
|
||||
const showMacroplan = computed(() => mode.value !== "source")
|
||||
|
||||
// Reflect the choice in the URL without stacking a history entry per toggle.
|
||||
// "split" is the default, so drop the param to keep the URL clean.
|
||||
watch(mode, (m) => {
|
||||
const url = new URL(location.href)
|
||||
if (m === "split") url.searchParams.delete("view")
|
||||
else url.searchParams.set("view", m)
|
||||
history.replaceState(history.state, "", url)
|
||||
})
|
||||
|
||||
const activeName = computed(
|
||||
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
|
||||
)
|
||||
|
||||
function downloadToml() {
|
||||
downloadSource(source.value, sourceFilename(plan.value?.title ?? activeName.value))
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
deletePlan(activeId.value)
|
||||
confirmingDelete.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,17 +54,77 @@ const { source, plan, error, resetToSample } = useMacroplan()
|
||||
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
|
||||
<img src="/favicon.svg" alt="" class="size-6" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-lg font-semibold leading-tight">Macroplan</h1>
|
||||
<p class="text-xs text-base-content/60 leading-tight">
|
||||
A week-granular, learning-oriented record of what we committed to deliver.
|
||||
</p>
|
||||
<PlanSwitcher
|
||||
:plans="plans"
|
||||
:active-id="activeId"
|
||||
@select="selectPlan"
|
||||
@new="newPlan"
|
||||
@download="downloadToml"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button>
|
||||
<div class="join" role="group" aria-label="Choose which panes to show">
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mode === 'source' }"
|
||||
:aria-pressed="mode === 'source'"
|
||||
title="Show only the TOML source"
|
||||
@click="mode = 'source'"
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mode === 'split' }"
|
||||
:aria-pressed="mode === 'split'"
|
||||
title="Show the source and the Macroplan side by side"
|
||||
@click="mode = 'split'"
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mode === 'macroplan' }"
|
||||
:aria-pressed="mode === 'macroplan'"
|
||||
title="Show only the rendered Macroplan"
|
||||
@click="mode = 'macroplan'"
|
||||
>
|
||||
Macroplan
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="!plan || busy || !showMacroplan"
|
||||
title="Copy the rendered plan to the clipboard as a PNG"
|
||||
@click="copyPng(exportRoot)"
|
||||
>
|
||||
<span v-if="busy" class="loading loading-spinner loading-xs"></span>
|
||||
Copy PNG
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="!plan || busy || !showMacroplan"
|
||||
title="Download the rendered plan as a PNG"
|
||||
@click="downloadPng(exportRoot, exportFilename(plan?.title ?? ''))"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="busy"
|
||||
title="Delete this plan"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<section
|
||||
class="flex min-h-0 flex-col border-base-300 max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r"
|
||||
v-if="showSource"
|
||||
class="flex min-h-0 flex-col border-base-300"
|
||||
:class="
|
||||
showMacroplan ? 'max-md:h-2/5 max-md:border-b md:w-1/3 md:max-w-md md:border-r' : 'flex-1'
|
||||
"
|
||||
>
|
||||
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Source (TOML)
|
||||
@@ -29,11 +132,59 @@ const { source, plan, error, resetToSample } = useMacroplan()
|
||||
<PlanEditor v-model="source" :error="error" class="min-h-0 flex-1" />
|
||||
</section>
|
||||
|
||||
<section class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<h2 v-if="plan" class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
|
||||
<MacroplanGrid v-if="plan" :plan="plan" />
|
||||
<p v-else class="text-sm text-base-content/60">Nothing to render yet — fix the source on the left.</p>
|
||||
<section v-if="showMacroplan" class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="plan" ref="exportRoot" class="export-root">
|
||||
<h2 class="mb-3 text-sm font-semibold text-base-content/70">{{ plan.title }}</h2>
|
||||
<MacroplanGrid :plan="plan" />
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/60">
|
||||
Nothing to render yet — fix the source on the left.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': confirmingDelete }">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-base font-semibold">Delete "{{ activeName }}"?</h3>
|
||||
<p class="py-2 text-sm text-base-content/70">
|
||||
This removes the plan from your library. Download its .toml first if you want to keep it.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-sm" @click="confirmingDelete = false">Cancel</button>
|
||||
<button class="btn btn-sm btn-error" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="confirmingDelete = false">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div v-if="toast" class="toast toast-end">
|
||||
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
|
||||
<span>{{ toast.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* While exporting, grow to the timeline's full width and un-clip the grid so the
|
||||
captured PNG shows every week column (not just the on-screen scroll window).
|
||||
The base-200 frame + padding give the snapshot a little breathing room. */
|
||||
.export-root.exporting {
|
||||
width: max-content;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
.export-root.exporting :deep(.macroplan) {
|
||||
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>
|
||||
|
||||
@@ -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,72 +1,71 @@
|
||||
<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.
|
||||
const NAME_W = 16 * 16
|
||||
const WK = 3.5 * 16
|
||||
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||
|
||||
const weeks = computed(() => props.plan.weeks)
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `minmax(7rem, max-content) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
|
||||
}))
|
||||
|
||||
function tone(row: FeatureRow): Tone {
|
||||
if (row.delivered) return row.onTime ? 'success' : 'error'
|
||||
if (row.status === 'green') return 'success'
|
||||
if (row.status === 'orange') return 'warning'
|
||||
if (row.status === 'red') 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 === 'green'
|
||||
? 'on track'
|
||||
: row.status === 'orange'
|
||||
? 'at risk'
|
||||
: row.status === 'red'
|
||||
? 'blocked'
|
||||
: 'in flight'
|
||||
return row.status ? row.status.replace("-", " ") : "in flight"
|
||||
}
|
||||
|
||||
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
@@ -78,9 +77,9 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
// how far the bar line runs within this cell: none, half-from-center-right,
|
||||
// half-from-left-to-center, or full width
|
||||
line: 'none' | 'right' | 'left' | 'full'
|
||||
// how far the bar line runs within this cell: none, center→right,
|
||||
// left→center, or full width
|
||||
line: "none" | "right" | "left" | "full"
|
||||
isStart: boolean
|
||||
glyph: string
|
||||
glyphCls: string
|
||||
@@ -92,53 +91,83 @@ 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 runs center→edge so it begins and ends at a glyph centre,
|
||||
// never stubbing past the first/last symbol
|
||||
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] : "",
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Per-week column metadata for headers + the now / milestone vertical rules.
|
||||
// Per-week column metadata for the header + the now / milestone column rules.
|
||||
const cols = computed(() =>
|
||||
weeks.value.map((w) => ({
|
||||
w,
|
||||
label: weekLabel(w),
|
||||
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
|
||||
milestones: props.plan.milestones.filter((m) => m.week === w),
|
||||
isMilestone: props.plan.milestones.some((m) => m.week === w),
|
||||
})),
|
||||
)
|
||||
|
||||
function colClass(i: number): string {
|
||||
const c = cols.value[i]
|
||||
if (c.isNow) return 'col-now'
|
||||
if (c.milestones.length) return 'col-ms'
|
||||
return ''
|
||||
if (c.isNow) return "col-now"
|
||||
if (c.isMilestone) return "col-ms"
|
||||
return ""
|
||||
}
|
||||
|
||||
function milestoneTitle(ms: Plan['milestones']): string {
|
||||
return ms
|
||||
.map((m) => (m.unmet.length ? `${m.name} — unmet: ${m.unmet.join(', ')}` : `${m.name} — all met`))
|
||||
.join(' · ')
|
||||
}
|
||||
// Milestone label flags above the axis, greedily stacked onto extra rows so
|
||||
// labels in nearby weeks never overlap. `col` is the 1-based grid column.
|
||||
const milestoneFlags = computed(() => {
|
||||
const items = props.plan.milestones
|
||||
.map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 }))
|
||||
.filter((x) => x.col >= 2)
|
||||
.sort((a, b) => a.col - b.col)
|
||||
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 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} — all required features met`
|
||||
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
|
||||
})
|
||||
})
|
||||
const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0))
|
||||
const bandStyle = computed(() => ({
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`,
|
||||
gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto rounded-box border border-base-300 bg-base-100">
|
||||
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
|
||||
<!-- milestone band: stacked label flags with leader lines down to the axis -->
|
||||
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
|
||||
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
|
||||
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
|
||||
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
|
||||
<span class="ms-dia">◆</span> {{ f.name
|
||||
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="plan-grid" :style="gridStyle">
|
||||
<!-- header row -->
|
||||
<div class="hcell corner">{{ plan.title }}</div>
|
||||
<div class="hcell corner">Feature</div>
|
||||
<div
|
||||
v-for="(c, ci) in cols"
|
||||
:key="'h-' + c.w"
|
||||
@@ -147,19 +176,13 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
>
|
||||
<span class="wklabel">{{ c.label }}</span>
|
||||
<span v-if="c.isNow" class="badge-now">now</span>
|
||||
<span
|
||||
v-else-if="c.milestones.length"
|
||||
class="badge-ms"
|
||||
:title="milestoneTitle(c.milestones)"
|
||||
>◆ {{ c.milestones[0].name
|
||||
}}<span v-if="c.milestones.some((m) => m.unmet.length)" class="text-error"> !</span></span>
|
||||
</div>
|
||||
<div class="hcell learnhead">Learning / Status</div>
|
||||
|
||||
<!-- feature rows -->
|
||||
<template v-for="(row, ri) in plan.rows" :key="row.name">
|
||||
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
|
||||
<span class="truncate">{{ row.name }}</span>
|
||||
<span class="truncate" :title="row.name">{{ row.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, ci) in matrix[ri]"
|
||||
@@ -174,7 +197,9 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
<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>
|
||||
@@ -192,20 +217,55 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
<span><b class="text-success">◉</b> on time</span>
|
||||
<span><b class="text-error">▲</b> late</span>
|
||||
<span><b>┣━</b> feature bar</span>
|
||||
<span><b class="text-error">◆</b> milestone</span>
|
||||
<span><b class="ms-dia">◆</b> milestone</span>
|
||||
<span><b class="now-swatch"></b> today</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-grid {
|
||||
.macroplan {
|
||||
--name-w: 16rem;
|
||||
--wk: 3.5rem;
|
||||
}
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
/* ── Milestone band ───────────────────────────────────────────────── */
|
||||
.ms-band {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-top: 0.35rem;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
.ms-flag {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-base-content);
|
||||
z-index: 1;
|
||||
}
|
||||
.ms-dia {
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
|
||||
}
|
||||
.ms-unmet {
|
||||
color: var(--color-error); /* unmet required features = a problem = red */
|
||||
font-weight: 700;
|
||||
}
|
||||
.ms-lead {
|
||||
justify-self: start;
|
||||
width: 0;
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hcell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -242,13 +302,6 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.badge-ms {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 600;
|
||||
max-width: 6rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.learnhead {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -346,15 +399,13 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.cell.col-now {
|
||||
background: color-mix(in oklch, var(--color-primary) 9%, var(--color-base-100));
|
||||
}
|
||||
/* "today" — a neutral grey column (red is reserved for problems) */
|
||||
.cell.col-now,
|
||||
.cell.col-now .glyph {
|
||||
/* match the column tint so the halo doesn't punch a hole in it */
|
||||
background: color-mix(in oklch, var(--color-primary) 9%, var(--color-base-100));
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
}
|
||||
.col-ms {
|
||||
border-left: 2px dashed color-mix(in oklch, var(--color-base-content) 35%, transparent);
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
}
|
||||
|
||||
.legend {
|
||||
@@ -374,7 +425,7 @@ function milestoneTitle(ms: Plan['milestones']): string {
|
||||
height: 0.8rem;
|
||||
vertical-align: -0.1rem;
|
||||
border-radius: 0.15rem;
|
||||
background: color-mix(in oklch, var(--color-primary) 9%, var(--color-base-100));
|
||||
border: 1px solid color-mix(in oklch, var(--color-primary) 30%, var(--color-base-100));
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
|
||||
}
|
||||
</style>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import { ref, computed, onMounted, nextTick } from "vue"
|
||||
import type { HighlighterCore } from "shiki/core"
|
||||
import { getCompletions, type CompletionContext } from "./completion"
|
||||
import { fitPopup } from "./popupFit"
|
||||
|
||||
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>()
|
||||
const popupEl = ref<HTMLElement>()
|
||||
const highlighter = ref<HighlighterCore>()
|
||||
|
||||
// ── Completion popup state ───────────────────────────────────────────────────
|
||||
const completion = ref<CompletionContext | null>(null)
|
||||
const selected = ref(0)
|
||||
const popup = ref({ left: 0, top: 0 })
|
||||
// Suppress the popup for one keystroke after Escape, so it doesn't pop straight
|
||||
// back open while the author is still typing the word they just dismissed.
|
||||
let justEscaped = false
|
||||
// Pixel metrics of the (monospace, non-wrapping) textarea, measured once.
|
||||
let metrics = { charWidth: 8, lineHeight: 20, padLeft: 16, padTop: 14 }
|
||||
|
||||
onMounted(async () => {
|
||||
measure()
|
||||
// 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],
|
||||
@@ -25,26 +39,151 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
function measure() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
const cs = getComputedStyle(el)
|
||||
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,
|
||||
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
|
||||
padLeft: parseFloat(cs.paddingLeft),
|
||||
padTop: parseFloat(cs.paddingTop),
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute suggestions from the live textarea value + caret. */
|
||||
function refresh() {
|
||||
const el = textarea.value
|
||||
if (!el) return
|
||||
completion.value = justEscaped ? null : getCompletions(el.value, el.selectionStart)
|
||||
selected.value = 0
|
||||
if (completion.value) position()
|
||||
}
|
||||
|
||||
/** Place the popup just below the caret (monospace ⇒ column × charWidth). */
|
||||
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)
|
||||
let col = 0
|
||||
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
|
||||
const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
|
||||
const caretTop = metrics.padTop + lineIndex * 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) {
|
||||
const el = textarea.value
|
||||
const ctx = completion.value
|
||||
if (!el || !ctx) return
|
||||
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))
|
||||
nextTick(() => {
|
||||
el.setSelectionRange(caret, caret)
|
||||
el.focus()
|
||||
// Chain only after completing a key (insert ends `= `), so its values pop
|
||||
// up next. A value or header is terminal — re-running would re-offer it.
|
||||
if (item.insert.endsWith("= ")) refresh()
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") justEscaped = false
|
||||
const ctx = completion.value
|
||||
if (!ctx) return
|
||||
const move = (delta: number) => {
|
||||
e.preventDefault()
|
||||
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
|
||||
}
|
||||
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
|
||||
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
|
||||
move(e.key === "n" ? 1 : -1)
|
||||
return
|
||||
}
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
move(1)
|
||||
break
|
||||
case "ArrowUp":
|
||||
move(-1)
|
||||
break
|
||||
case "Enter":
|
||||
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()
|
||||
accept(selected.value)
|
||||
break
|
||||
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":
|
||||
completion.value = null
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// Deleting text (backspace, forward-delete, cut) shouldn't summon the popup —
|
||||
// the author is removing, not asking for suggestions. Close it and stop.
|
||||
if ((e as InputEvent).inputType?.startsWith("delete")) {
|
||||
completion.value = null
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
function syncScroll() {
|
||||
if (!textarea.value || !backdrop.value) return
|
||||
backdrop.value.scrollTop = textarea.value.scrollTop
|
||||
backdrop.value.scrollLeft = textarea.value.scrollLeft
|
||||
if (completion.value) position()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,7 +202,25 @@ function syncScroll() {
|
||||
aria-label="Macroplan TOML source"
|
||||
@input="onInput"
|
||||
@scroll="syncScroll"
|
||||
@keydown="onKeydown"
|
||||
@blur="completion = null"
|
||||
></textarea>
|
||||
<ul
|
||||
v-if="completion"
|
||||
ref="popupEl"
|
||||
class="completion"
|
||||
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||
>
|
||||
<li
|
||||
v-for="(item, i) in completion.items"
|
||||
:key="item.label"
|
||||
:class="{ active: i === selected }"
|
||||
@mousedown.prevent="accept(i)"
|
||||
>
|
||||
<span class="label">{{ item.label }}</span>
|
||||
<span v-if="item.detail" class="detail">{{ item.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="error" class="editor-error" role="alert">
|
||||
<span class="font-semibold">Can’t parse:</span> {{ error }}
|
||||
@@ -95,10 +252,13 @@ function syncScroll() {
|
||||
padding: 0.9rem 1rem;
|
||||
font-family: inherit; /* Fira Code, from the global theme */
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
/* Integer px, NOT a unitless ratio: a fractional line box (0.8rem × 1.6 =
|
||||
20.48px) is rounded differently by a <textarea> than by the <pre> backdrop,
|
||||
so the caret drifts further from its glyph the more lines precede it —
|
||||
visible at the bottom of a tall file. An integer height rounds identically. */
|
||||
line-height: 20px;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
.backdrop {
|
||||
z-index: 0;
|
||||
@@ -120,6 +280,11 @@ function syncScroll() {
|
||||
}
|
||||
.input {
|
||||
z-index: 1;
|
||||
/* A textarea has an intrinsic block size (its `rows`), so inset:0 alone won't
|
||||
stretch it the way it does the plain-div backdrop — pin both axes to fill. */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
@@ -129,6 +294,42 @@ function syncScroll() {
|
||||
.input::selection {
|
||||
background: color-mix(in oklch, var(--color-primary) 28%, transparent);
|
||||
}
|
||||
/* Completion popup, anchored just below the caret (see position()). */
|
||||
.completion {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
min-width: 16rem;
|
||||
max-width: 30rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
background: var(--color-base-100);
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: 0 8px 24px color-mix(in oklch, black 16%, transparent);
|
||||
}
|
||||
.completion li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.completion li.active {
|
||||
background: color-mix(in oklch, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
.completion .label {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.completion .detail {
|
||||
font-size: 0.68rem;
|
||||
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
||||
}
|
||||
.editor-error {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.74rem;
|
||||
|
||||
33
src/components/PlanSwitcher.test.ts
Normal file
33
src/components/PlanSwitcher.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
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" },
|
||||
]
|
||||
|
||||
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 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"])
|
||||
})
|
||||
})
|
||||
41
src/components/PlanSwitcher.vue
Normal file
41
src/components/PlanSwitcher.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
const props = defineProps<{
|
||||
plans: { id: string; name: string }[]
|
||||
activeId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
new: []
|
||||
download: [id: string]
|
||||
}>()
|
||||
|
||||
const activeName = computed(
|
||||
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1 normal-case">
|
||||
<span class="max-w-[14rem] truncate font-semibold">{{ activeName }}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content z-50 mt-1 w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li v-for="p in plans" :key="p.id">
|
||||
<a :class="{ active: p.id === activeId }" @click="emit('select', p.id)">
|
||||
<span class="truncate">{{ p.name }}</span>
|
||||
<span v-if="p.id === activeId" aria-hidden="true">✓</span>
|
||||
</a>
|
||||
</li>
|
||||
<div class="my-1 border-t border-base-200"></div>
|
||||
<li><a @click="emit('new')">+ New plan</a></li>
|
||||
<li><a @click="emit('download', activeId)">⤓ Download .toml</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
181
src/components/completion.test.ts
Normal file
181
src/components/completion.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
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("|")
|
||||
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
|
||||
}
|
||||
|
||||
/** The labels offered at the `|` marker. */
|
||||
function labelsAt(marked: string): string[] {
|
||||
const [source, caret] = atCaret(marked)
|
||||
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
|
||||
}
|
||||
|
||||
describe("completion context", () => {
|
||||
it("offers plan keys on an empty top-level line", () => {
|
||||
// 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, no headers on the first line", () => {
|
||||
expect(labelsAt("[[feature]]\n|")).toEqual([
|
||||
"name",
|
||||
"start",
|
||||
"original",
|
||||
"reestimates",
|
||||
"delivered",
|
||||
"status",
|
||||
"learning",
|
||||
"note",
|
||||
])
|
||||
})
|
||||
|
||||
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
|
||||
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
|
||||
})
|
||||
|
||||
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",
|
||||
"requires",
|
||||
"[[feature]]",
|
||||
"[[milestone]]",
|
||||
])
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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("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("offers nothing once the status value's quote is closed", () => {
|
||||
expect(getCompletions(...atCaret('[[feature]]\nstatus = "on-track"|'))).toBeNull()
|
||||
})
|
||||
|
||||
it("suggests feature names inside a milestone requires array", () => {
|
||||
const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
|
||||
expect(labelsAt(source)).toEqual(["Payments"])
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe("date completion", () => {
|
||||
const TODAY = "2026-06-19"
|
||||
const datesAt = (marked: string) => {
|
||||
const [source, caret] = atCaret(marked)
|
||||
return getCompletions(source, caret, TODAY)
|
||||
}
|
||||
|
||||
it("suggests today then the current-year prefix for an empty date value", () => {
|
||||
const ctx = datesAt("[[feature]]\nstart = |")!
|
||||
expect(ctx.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
|
||||
expect(ctx.items.map((i) => i.detail)).toEqual(["today", "this year"])
|
||||
})
|
||||
|
||||
it("offers the same for plan dates and milestone weeks", () => {
|
||||
expect(datesAt("end = |")!.items.map((i) => i.insert)).toEqual(["2026-06-19", "2026-"])
|
||||
expect(datesAt("[[milestone]]\nweek = |")!.items.map((i) => i.insert)).toEqual([
|
||||
"2026-06-19",
|
||||
"2026-",
|
||||
])
|
||||
})
|
||||
|
||||
it("inserts at the caret, replacing nothing", () => {
|
||||
const [source, caret] = atCaret("[[feature]]\ndelivered = |")
|
||||
const ctx = getCompletions(source, caret, TODAY)!
|
||||
expect([ctx.from, ctx.to]).toEqual([caret, caret])
|
||||
})
|
||||
|
||||
it("offers a date for a fresh element in a reestimates array", () => {
|
||||
expect(datesAt("[[feature]]\nreestimates = [|")!.items.map((i) => i.insert)).toEqual([
|
||||
"2026-06-19",
|
||||
"2026-",
|
||||
])
|
||||
expect(
|
||||
datesAt("[[feature]]\nreestimates = [2026-01-01, |")!.items.map((i) => i.insert),
|
||||
).toEqual(["2026-06-19", "2026-"])
|
||||
})
|
||||
|
||||
it("stays out of the way once a date is being typed", () => {
|
||||
expect(datesAt("[[feature]]\nstart = 2|")).toBeNull()
|
||||
expect(datesAt("[[feature]]\nstart = 2026-|")).toBeNull()
|
||||
expect(datesAt("[[feature]]\nreestimates = [2026-0|")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("completion replace range", () => {
|
||||
it("replaces the typed prefix, not the whole line", () => {
|
||||
const source = "[[feature]]\nst"
|
||||
const ctx = getCompletions(source, source.length)!
|
||||
expect(source.slice(ctx.from, ctx.to)).toBe("st")
|
||||
})
|
||||
|
||||
it("replaces a partial bracketed header from the bracket", () => {
|
||||
const source = "[[f"
|
||||
const ctx = getCompletions(source, source.length)!
|
||||
expect(source.slice(ctx.from, ctx.to)).toBe("[[f")
|
||||
expect(ctx.items[0].insert).toBe("[[feature]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("bracket header block separation", () => {
|
||||
/** The inserts offered at the `|` marker. */
|
||||
const insertsAt = (marked: string) => {
|
||||
const caret = marked.indexOf("|")
|
||||
const source = marked.slice(0, caret) + marked.slice(caret + 1)
|
||||
return getCompletions(source, caret)!.items.map((i) => i.insert)
|
||||
}
|
||||
|
||||
it("prepends a blank line when a bare [ glues onto the block above", () => {
|
||||
// No blank line above ⇒ accepting must not weld the new block to the old one.
|
||||
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
|
||||
})
|
||||
|
||||
it("adds no leading newline when a blank line already separates the block", () => {
|
||||
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
|
||||
})
|
||||
|
||||
it("adds no leading newline at the very top of the document", () => {
|
||||
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
|
||||
})
|
||||
})
|
||||
222
src/components/completion.ts
Normal file
222
src/components/completion.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// Schema-aware completion for the Macroplan TOML editor.
|
||||
//
|
||||
// The key / value lists below mirror the schema in `src/model/parse.ts`.
|
||||
// Keep them in sync when fields are added, removed, or renamed.
|
||||
|
||||
export interface Completion {
|
||||
/** Text shown in the popup. */
|
||||
label: string
|
||||
/** Text inserted in place of source[from..to]. */
|
||||
insert: string
|
||||
/** Greyed hint shown after the label. */
|
||||
detail?: string
|
||||
/** Used for prefix matching when it differs from `label` (e.g. headers). */
|
||||
filter?: string
|
||||
}
|
||||
|
||||
export interface CompletionContext {
|
||||
/** Replace source[from..to] with the chosen completion's `insert`. */
|
||||
from: number
|
||||
to: number
|
||||
items: Completion[]
|
||||
}
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
const HEADERS: Completion[] = [
|
||||
{ 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
|
||||
|
||||
/** What to suggest at `caret` in `source`, or null when nothing applies.
|
||||
* `today` (yyyy-mm-dd) seeds the date suggestions; injectable for tests. */
|
||||
export function getCompletions(
|
||||
source: string,
|
||||
caret: number,
|
||||
today: string = todayYmd(),
|
||||
): CompletionContext | null {
|
||||
const lineStart = source.lastIndexOf("\n", caret - 1) + 1
|
||||
const linePrefix = source.slice(lineStart, caret)
|
||||
|
||||
// ── value: status = "<here>" — while typing, not once the quote is closed ──
|
||||
const status = /^(\s*status\s*=\s*)"?([A-Za-z-]*)$/.exec(linePrefix)
|
||||
if (status) {
|
||||
const items = filter(
|
||||
STATUSES.map((s) => ({ label: s, insert: `"${s}"` })),
|
||||
status[2],
|
||||
)
|
||||
return result(lineStart + status[1].length, caret, items)
|
||||
}
|
||||
|
||||
// ── value: a date field — start = <here>, week = <here>, … ────────────────
|
||||
// Only on an empty value, so it never fights a date already being typed
|
||||
// (the chained refresh after picking the key surfaces these immediately).
|
||||
if (/^\s*(?:start|end|original|delivered|week)\s*=\s*$/.test(linePrefix)) {
|
||||
return result(caret, caret, dateItems(today))
|
||||
}
|
||||
|
||||
// ── value: reestimates = [ … <here> ] — a date for a fresh array element ──
|
||||
const inReestimates = /reestimates\s*=\s*\[[^\]]*$/.test(linePrefix)
|
||||
if (inReestimates && /[[,]\s*$/.test(linePrefix)) {
|
||||
return result(caret, caret, dateItems(today))
|
||||
}
|
||||
|
||||
const block = currentBlock(source, lineStart)
|
||||
|
||||
// ── value: requires = [ "<here>" … ] (milestones only) ───────────────────
|
||||
if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
|
||||
const token = /("?)([^",[\]]*)$/.exec(linePrefix)!
|
||||
const items = filter(
|
||||
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
|
||||
token[2],
|
||||
)
|
||||
return result(caret - token[1].length - token[2].length, caret, items)
|
||||
}
|
||||
|
||||
// ── key / header position: only indentation then an optional word ─────────
|
||||
const key = /^(\s*)(\[*)([A-Za-z]*)$/.exec(linePrefix)
|
||||
if (key) {
|
||||
const [, indent, brackets, word] = key
|
||||
if (brackets) {
|
||||
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
|
||||
}
|
||||
const keys =
|
||||
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
||||
const taken = presentKeys(source, lineStart, block)
|
||||
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 null
|
||||
}
|
||||
|
||||
function result(from: number, to: number, items: Completion[]): CompletionContext | null {
|
||||
return items.length ? { from, to, items } : null
|
||||
}
|
||||
|
||||
/** Header completions for a bare `[`. When the block above isn't already set off
|
||||
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
|
||||
* never glues the new block onto the previous one. (At the top of the document
|
||||
* there's nothing to separate, so no gap is added.) */
|
||||
function headerItems(source: string, lineStart: number): Completion[] {
|
||||
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
|
||||
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
|
||||
}
|
||||
|
||||
/** Is the line directly above the caret's line blank (empty or whitespace only)?
|
||||
* Gates header suggestions to the "two new lines" rule — see the key branch. */
|
||||
function blankLineAbove(source: string, lineStart: number): boolean {
|
||||
if (lineStart === 0) return false // top of the document — nothing above
|
||||
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
|
||||
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
|
||||
}
|
||||
|
||||
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
||||
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
||||
function todayYmd(): string {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** Date suggestions: the full date (easy to tweak the day/month in place) and
|
||||
* the current-year prefix (accept it, then type the month-day). */
|
||||
function dateItems(today: string): Completion[] {
|
||||
return [
|
||||
{ label: today, insert: today, detail: "today" },
|
||||
{ label: today.slice(0, 5), insert: today.slice(0, 5), detail: "this year" },
|
||||
]
|
||||
}
|
||||
|
||||
function filter(items: Completion[], word: string): Completion[] {
|
||||
if (!word) return items
|
||||
const w = word.toLowerCase()
|
||||
return items.filter((i) => (i.filter ?? i.label).toLowerCase().startsWith(w))
|
||||
}
|
||||
|
||||
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"
|
||||
HEADER_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
|
||||
block = m[1] as Block
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** Keys already written in the current block, so we don't offer duplicates. */
|
||||
function presentKeys(source: string, lineStart: number, block: Block): Set<string> {
|
||||
const headers: number[] = []
|
||||
HEADER_RE.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = HEADER_RE.exec(source)) !== null) headers.push(m.index)
|
||||
|
||||
let start = 0
|
||||
let end = source.length
|
||||
if (block === "plan") {
|
||||
end = headers[0] ?? source.length
|
||||
} else {
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
if (headers[i] < lineStart) {
|
||||
start = headers[i]
|
||||
end = headers[i + 1] ?? source.length
|
||||
} else break
|
||||
}
|
||||
}
|
||||
|
||||
const taken = new Set<string>()
|
||||
const region = source.slice(start, end)
|
||||
const keyLine = /^[ \t]*([A-Za-z][\w-]*)\s*=/gm
|
||||
while ((m = keyLine.exec(region)) !== null) taken.add(m[1])
|
||||
return taken
|
||||
}
|
||||
|
||||
/** Names declared in `[[feature]]` blocks — the valid targets for `requires`. */
|
||||
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 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])
|
||||
}
|
||||
return [...names]
|
||||
}
|
||||
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 }
|
||||
}
|
||||
125
src/composables/useMacroplan.test.ts
Normal file
125
src/composables/useMacroplan.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// @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"
|
||||
|
||||
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", () => {
|
||||
const m = useMacroplan()
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(m.plans.value[0].id)
|
||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
||||
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", () => {
|
||||
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(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")
|
||||
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", () => {
|
||||
// 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" }] }),
|
||||
)
|
||||
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", () => {
|
||||
localStorage.setItem(
|
||||
LIB_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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
|
||||
m.deletePlan(second.id)
|
||||
expect(m.plans.value).toHaveLength(1)
|
||||
expect(m.activeId.value).toBe(first.id)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const m = useMacroplan()
|
||||
const aId = m.activeId.value
|
||||
m.newPlan()
|
||||
const bId = m.activeId.value
|
||||
m.source.value = 'title = "B"\n[[feature]]\n' // B broken
|
||||
await nextTick()
|
||||
m.selectPlan(aId)
|
||||
await nextTick()
|
||||
expect(m.plan.value).toBeTruthy() // A renders
|
||||
m.selectPlan(bId)
|
||||
await nextTick()
|
||||
expect(m.error.value).toBeTruthy() // B's parse error surfaces
|
||||
expect(m.plan.value).toBeNull() // no stale A render leaks through
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,108 @@
|
||||
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, 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:source'
|
||||
const STORAGE_KEY = "macroplan:library"
|
||||
const LEGACY_KEY = "macroplan:source"
|
||||
|
||||
const StoredPlanSchema = v.object({
|
||||
id: v.string(),
|
||||
name: v.string(),
|
||||
source: v.string(),
|
||||
})
|
||||
export type StoredPlan = v.InferOutput<typeof StoredPlanSchema>
|
||||
|
||||
const LibrarySchema = v.object({
|
||||
version: v.literal(1),
|
||||
activeId: v.string(),
|
||||
plans: v.array(StoredPlanSchema),
|
||||
})
|
||||
type Library = v.InferOutput<typeof LibrarySchema>
|
||||
|
||||
/** The source's title if it fully parses, else null (so the cached name only
|
||||
* ever updates on a valid parse). */
|
||||
function titleOf(source: string): string | null {
|
||||
try {
|
||||
return parseMacroplan(source).title
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function newStoredPlan(source: string): StoredPlan {
|
||||
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
|
||||
}
|
||||
|
||||
function save(lib: Library): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(lib))
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the initial library: existing store → legacy migration → fresh seed.
|
||||
* Always persisted before returning so a migrated/seeded library survives a
|
||||
* reload even if the user never edits. */
|
||||
function loadLibrary(): Library {
|
||||
let result: Library | null = null
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = v.safeParse(LibrarySchema, JSON.parse(raw))
|
||||
if (parsed.success && parsed.output.plans.length > 0) {
|
||||
const lib = parsed.output
|
||||
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
|
||||
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
|
||||
}
|
||||
} catch {
|
||||
/* corrupt JSON → fall through to migration / seed */
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const legacy = localStorage.getItem(LEGACY_KEY)
|
||||
if (legacy != null) {
|
||||
const p = newStoredPlan(legacy)
|
||||
localStorage.removeItem(LEGACY_KEY)
|
||||
result = { version: 1, activeId: p.id, plans: [p] }
|
||||
} else {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
result = { version: 1, activeId: seed.id, plans: [seed] }
|
||||
}
|
||||
}
|
||||
|
||||
save(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoring state for a single Macroplan: the TOML source (autosaved to
|
||||
* localStorage), the parsed Plan, and the current parse error. The last
|
||||
* successfully-parsed Plan keeps rendering through transient typos (F3).
|
||||
* Owns the Library — the collection of named Macroplans in localStorage
|
||||
* (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
|
||||
* error, with the last good render kept through transient typos, F3) plus the
|
||||
* switch / create / delete operations over the library.
|
||||
*/
|
||||
export function useMacroplan() {
|
||||
const source = ref(localStorage.getItem(STORAGE_KEY) ?? SAMPLE_PLAN)
|
||||
const lastGood = ref<Plan | null>(null)
|
||||
const lib = ref<Library>(loadLibrary())
|
||||
const lastGood = shallowRef<Plan | null>(null)
|
||||
|
||||
const active = computed<StoredPlan>(
|
||||
() => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0],
|
||||
)
|
||||
|
||||
const source = computed<string>({
|
||||
get: () => active.value.source,
|
||||
set: (v) => {
|
||||
const p = active.value
|
||||
p.source = v
|
||||
const t = titleOf(v)
|
||||
if (t) p.name = t // refresh the cached label only on a valid parse
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
|
||||
try {
|
||||
@@ -31,20 +120,47 @@ export function useMacroplan() {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(source, (v) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v)
|
||||
} catch {
|
||||
/* localStorage may be full or blocked — autosave is best-effort */
|
||||
// Persist the whole library on any change (best-effort, like the old autosave).
|
||||
watch(lib, (l) => save(l), { deep: true })
|
||||
|
||||
// Reset last-good to the target plan's current parse (null if it's broken),
|
||||
// so switching to a broken plan shows its own error/empty state rather than
|
||||
// the previous plan's grid.
|
||||
function switchTo(id: string): void {
|
||||
lib.value.activeId = id
|
||||
lastGood.value = parsed.value.plan
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
source,
|
||||
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
||||
error: computed(() => parsed.value.error),
|
||||
resetToSample: () => {
|
||||
source.value = SAMPLE_PLAN
|
||||
plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
|
||||
activeId: computed(() => lib.value.activeId),
|
||||
selectPlan: (id: string) => {
|
||||
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
|
||||
},
|
||||
newPlan: () => {
|
||||
// A new plan starts blank. The bundled sample is reserved for a genuinely
|
||||
// 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("")
|
||||
lib.value.plans.push(p)
|
||||
switchTo(p.id)
|
||||
},
|
||||
deletePlan: (id: string) => {
|
||||
const idx = lib.value.plans.findIndex((p) => p.id === id)
|
||||
if (idx === -1) return
|
||||
const wasActive = lib.value.activeId === id
|
||||
lib.value.plans.splice(idx, 1)
|
||||
if (lib.value.plans.length === 0) {
|
||||
const seed = newStoredPlan(SAMPLE_PLAN)
|
||||
lib.value.plans.push(seed)
|
||||
switchTo(seed.id)
|
||||
} else if (wasActive) {
|
||||
switchTo(lib.value.plans[Math.max(0, idx - 1)].id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
17
src/composables/usePngExport.test.ts
Normal file
17
src/composables/usePngExport.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
89
src/composables/usePngExport.ts
Normal file
89
src/composables/usePngExport.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
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, "")
|
||||
}
|
||||
|
||||
/** Slugified, stable download name derived from the plan title. */
|
||||
export function exportFilename(title: string): string {
|
||||
return `macroplan-${slugify(title) || "plan"}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side PNG export of a rendered Macroplan (C6 / F7). Captures the full
|
||||
* grid — not just the scrolled viewport — by expanding the target to its
|
||||
* content width for the duration of the render, then copies to the clipboard
|
||||
* or downloads. html-to-image is dynamically imported so it stays out of the
|
||||
* initial bundle (like Shiki in the editor).
|
||||
*/
|
||||
export function usePngExport() {
|
||||
const busy = ref(false)
|
||||
const toast = ref<Toast>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
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")
|
||||
// `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")
|
||||
// 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")
|
||||
return blob
|
||||
} finally {
|
||||
el.classList.remove("exporting")
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPng(el?: HTMLElement | null) {
|
||||
if (!el || busy.value) return
|
||||
busy.value = true
|
||||
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")
|
||||
} catch {
|
||||
flash("err", "Couldn’t copy — use Download instead")
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
flash("ok", "PNG downloaded")
|
||||
} catch {
|
||||
flash("err", "Export failed — please retry")
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { busy, toast, copyPng, downloadPng }
|
||||
}
|
||||
13
src/composables/useSourceExport.test.ts
Normal file
13
src/composables/useSourceExport.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
17
src/composables/useSourceExport.ts
Normal file
17
src/composables/useSourceExport.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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`
|
||||
}
|
||||
|
||||
/** 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 url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
// Default Macroplan shown on first load — exercises every state:
|
||||
// on-time delivery, late delivery with slips, in-flight (green/orange/red),
|
||||
// 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.
|
||||
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.
|
||||
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
|
||||
# Omit both to auto-fit the columns to the Features and Milestones.
|
||||
start = 2026-05-25
|
||||
end = 2026-08-03
|
||||
|
||||
# A Feature: start week, Original Estimate (the immovable baseline), then any
|
||||
# Re-estimates, an optional Delivery, and an optional Learning / Status.
|
||||
@@ -26,7 +35,7 @@ learning = "Vendor lead time was the real constraint — derisk vendors up front
|
||||
name = "Dashboard"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-08 # already past 'now' and undelivered → overdue
|
||||
status = "red"
|
||||
status = "off-track"
|
||||
note = "No recovery plan yet — needs an owner."
|
||||
|
||||
[[feature]]
|
||||
@@ -34,17 +43,27 @@ name = "Search"
|
||||
start = 2026-06-08
|
||||
original = 2026-06-22
|
||||
reestimates = [2026-07-06] # re-estimated once, still in flight → △
|
||||
status = "orange"
|
||||
status = "at-risk"
|
||||
note = "Third-party search API is flaky; spike a fallback."
|
||||
|
||||
[[feature]]
|
||||
name = "Notifications"
|
||||
start = 2026-06-22
|
||||
original = 2026-07-06
|
||||
status = "green"
|
||||
status = "on-track"
|
||||
|
||||
[[milestone]]
|
||||
name = "Code freeze"
|
||||
week = 2026-06-29
|
||||
requires = ["Auth", "Payments"]
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP go-live"
|
||||
week = 2026-07-06
|
||||
requires = ["Auth", "Payments", "Dashboard"]
|
||||
|
||||
[[milestone]]
|
||||
name = "Beta launch"
|
||||
week = 2026-07-13
|
||||
requires = ["Search", "Notifications"]
|
||||
`
|
||||
|
||||
@@ -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,16 +1,54 @@
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import { toYmd } from './week'
|
||||
import type { RawPlan, RawFeature, RawMilestone, 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: StatusLevel[] = ['green', 'orange', 'red']
|
||||
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.transform((value: string | Date) => toYmd(value)),
|
||||
)
|
||||
|
||||
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
|
||||
|
||||
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"), []),
|
||||
delivered: v.optional(Ymd),
|
||||
learning: v.optional(v.string()),
|
||||
status: v.optional(Status),
|
||||
note: v.optional(v.string()),
|
||||
})
|
||||
|
||||
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"),
|
||||
[],
|
||||
),
|
||||
})
|
||||
|
||||
/** 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. */
|
||||
export function parseMacroplan(source: string): RawPlan {
|
||||
@@ -21,65 +59,94 @@ export function parseMacroplan(source: string): RawPlan {
|
||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
const features = asBlocks(data.feature, 'feature').map(parseFeature)
|
||||
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
|
||||
checkVersion(data.macroplan_version)
|
||||
|
||||
const features = asBlocks(data.feature, "feature").map((f, i) =>
|
||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||
)
|
||||
requireUniqueFeatureNames(features)
|
||||
|
||||
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,
|
||||
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
||||
features,
|
||||
milestones,
|
||||
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
||||
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function asBlocks(value: unknown, key: string): Record<string, unknown>[] {
|
||||
/** 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[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
||||
}
|
||||
return value as Record<string, unknown>[]
|
||||
return value
|
||||
}
|
||||
|
||||
function parseFeature(f: Record<string, unknown>, i: number): RawFeature {
|
||||
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
|
||||
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
|
||||
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
|
||||
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
|
||||
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
|
||||
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
|
||||
}
|
||||
if (f.reestimates != null && !Array.isArray(f.reestimates)) {
|
||||
throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`)
|
||||
}
|
||||
return {
|
||||
name: String(f.name),
|
||||
start: toYmdOr(where, 'start', f.start),
|
||||
original: toYmdOr(where, 'original', f.original),
|
||||
reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)),
|
||||
delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined,
|
||||
learning: f.learning != null ? String(f.learning) : undefined,
|
||||
status: f.status as StatusLevel | undefined,
|
||||
note: f.note != null ? String(f.note) : undefined,
|
||||
}
|
||||
/** "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 as { name: unknown }).name
|
||||
: undefined
|
||||
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
|
||||
}
|
||||
|
||||
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
|
||||
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
|
||||
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
|
||||
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
|
||||
if (m.requires != null && !Array.isArray(m.requires)) {
|
||||
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
|
||||
}
|
||||
return {
|
||||
name: String(m.name),
|
||||
week: toYmdOr(where, 'week', m.week),
|
||||
requires: ((m.requires as unknown[]) ?? []).map(String),
|
||||
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
|
||||
function check<S extends v.GenericSchema>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
where: string,
|
||||
field?: string,
|
||||
): v.InferOutput<S> {
|
||||
const result = v.safeParse(schema, value)
|
||||
if (!result.success) {
|
||||
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
|
||||
}
|
||||
return result.output
|
||||
}
|
||||
|
||||
function toYmdOr(where: string, field: string, value: unknown): string {
|
||||
try {
|
||||
return toYmd(value)
|
||||
} catch {
|
||||
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
|
||||
}
|
||||
type Issue = {
|
||||
readonly message: string
|
||||
readonly received: string
|
||||
readonly path?: ReadonlyArray<{ readonly key: unknown }>
|
||||
}
|
||||
|
||||
/** Render an issue against the offending field, e.g. "`start` must be a date"
|
||||
* — or "missing `original`" when the key is absent (valibot reports its own
|
||||
* "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"
|
||||
return field ? `\`${field}\` ${issue.message}` : issue.message
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 exampleToml from "../../docs/macroplan.example.toml?raw"
|
||||
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,136 +15,209 @@ 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 = "red"'), '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('red')
|
||||
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 = "red"'), '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-06-01') // earliest start
|
||||
expect(plan.weeks.at(-1)).toBe('2026-07-20') // latest marker (Payments delivery)
|
||||
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('parse validation', () => {
|
||||
it('rejects a feature missing its Original Estimate', () => {
|
||||
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`", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
|
||||
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", () => {
|
||||
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(1)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -25,7 +25,11 @@ export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan
|
||||
})
|
||||
|
||||
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
|
||||
// Optional authored `start`/`end` widen this span with lead-in / trailing weeks;
|
||||
// they only ever extend it — a Feature or marker outside them is never clipped.
|
||||
const allWeeks: WeekId[] = []
|
||||
if (raw.start != null) allWeeks.push(mondayOf(raw.start))
|
||||
if (raw.end != null) allWeeks.push(mondayOf(raw.end))
|
||||
for (const r of rows) {
|
||||
allWeeks.push(r.startWeek, r.barEndWeek)
|
||||
for (const mk of r.markers) allWeeks.push(mk.week)
|
||||
@@ -52,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 = 'green' | 'orange' | 'red'
|
||||
export type StatusLevel = "on-track" | "at-risk" | "off-track"
|
||||
|
||||
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface RawMilestone {
|
||||
|
||||
export interface RawPlan {
|
||||
title: string
|
||||
start?: string // yyyy-mm-dd — optional authored left edge of the plan's span
|
||||
end?: string // yyyy-mm-dd — optional authored right edge of the plan's span
|
||||
features: RawFeature[]
|
||||
milestones: RawMilestone[]
|
||||
}
|
||||
@@ -30,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