9.8 KiB
Plans Library — Design
Date: 2026-06-17
Status: Approved (brainstorming) → ready for implementation plan
Realizes: ADR-0002 — "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 (seeded from the bundled sample).
- 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
.tomlimport — pasting a.tomlfile'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:
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:
- If
macroplan:libraryexists and parses → use it. - Else if the legacy
macroplan:sourceexists → wrap that source as a single plan (name= its parsed title, or"Untitled"if it doesn't parse), mark it active, writemacroplan:library, and remove the legacy key. - 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 sample-seeded plan and switch to it. |
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() (seeded from the sample) 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 planand⤓ Download .tomlentries. - No storage or parsing logic — it only reflects props and emits intent.
downloadSource + slugify (new util) — .toml export
- Refactor
exportFilename(inusePngExport.ts) to build on an extractedslugify(title): stringhelper, so PNG and.tomlnames 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 existingdownloadPngmechanics. Lives in a small util (e.g.src/composables/useSourceExport.ts) — kept separate fromusePngExportsince 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 PNGandDownload(PNG) buttons are unchanged —.tomldownload lives in the switcher dropdown precisely so there is no competing "Download" label in the top bar.- Wire
PlanSwitcherevents: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 sample 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:libraryon 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.randomUUIDunavailable: 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:sourcepresent → migrates to a one-plan library (name = parsed title), legacy key removed. - Corrupt
macroplan:library→ seeds fresh sample (no throw). newPlan()appends a sample plan and makes it active.deletePlan(active)removes it and re-pointsactiveIdto a remaining plan.deletePlanof the last plan re-seeds a fresh sample (invariant holds).- Editing
sourceupdates the active entry and refreshes its cachednameon a valid title; a broken edit keeps the last-goodnameand render. selectPlanswapssource/plan/errorand resetslastGood(broken target shows its own error, not the prior render).
Filename util:
sourceFilename(title)slug mirrors the existingexportFilenametest (e.g."Q3 Delivery"→macroplan-q3-delivery.toml); empty/garbage title →macroplan-plan.toml.
Component (@vue/test-utils):
PlanSwitcherrenders the plan names, marks the active one, and emitsselect/new/delete/downloadon 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 +.tomlexport.
Open questions
None blocking. Possible future follow-ups (out of scope): .toml file-open import, duplicate-plan, and reordering.