# 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.