From 3a012f186d98e43a4c76f436fc0e5dde71bcfe46 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 17 Jun 2026 09:40:40 +0200 Subject: [PATCH] feat(model): make useMacroplan own a library of named plans --- src/composables/useMacroplan.test.ts | 113 ++++++++++++++++++++++ src/composables/useMacroplan.ts | 139 ++++++++++++++++++++++++--- 2 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 src/composables/useMacroplan.test.ts diff --git a/src/composables/useMacroplan.test.ts b/src/composables/useMacroplan.test.ts new file mode 100644 index 0000000..6271589 --- /dev/null +++ b/src/composables/useMacroplan.test.ts @@ -0,0 +1,113 @@ +// @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 + }) +}) diff --git a/src/composables/useMacroplan.ts b/src/composables/useMacroplan.ts index 682c1e0..cdbe837 100644 --- a/src/composables/useMacroplan.ts +++ b/src/composables/useMacroplan.ts @@ -1,19 +1,104 @@ -import { ref, computed, watch } from 'vue' +import { ref, shallowRef, 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:source' +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 +} /** - * 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(null) + const lib = ref(loadLibrary()) + const lastGood = shallowRef(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 { @@ -31,18 +116,44 @@ 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 }) + + // 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 },