diff --git a/docs/superpowers/plans/2026-06-17-plans-library.md b/docs/superpowers/plans/2026-06-17-plans-library.md new file mode 100644 index 0000000..01c20ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-plans-library.md @@ -0,0 +1,846 @@ +# 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 (` + + +``` + +- [ ] **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` — reads/writes the active plan's source + - `plan: ComputedRef` + - `error: ComputedRef` + - `plans: ComputedRef<{ id: string; name: string }[]>` + - `activeId: ComputedRef` + - `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(loadLibrary()) + const lastGood = ref(null) + + const active = computed( + () => lib.value.plans.find((p) => p.id === lib.value.activeId) ?? lib.value.plans[0], + ) + + const source = computed({ + 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 + + + + + +``` + +- [ ] **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-.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.