Compare commits
3 Commits
59bedda78b
...
35c84cf943
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c84cf943 | ||
|
|
b876bca083 | ||
|
|
c4765d9630 |
@@ -1,11 +1,11 @@
|
|||||||
# Macroplan
|
# 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
|
## Language
|
||||||
|
|
||||||
**Macroplan**:
|
**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
|
_Avoid_: Gantt, roadmap, timeline
|
||||||
|
|
||||||
**Feature**:
|
**Feature**:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Macroplan
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
846
docs/superpowers/plans/2026-06-17-plans-library.md
Normal file
846
docs/superpowers/plans/2026-06-17-plans-library.md
Normal file
@@ -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 (`<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.
|
||||||
158
docs/superpowers/specs/2026-06-17-plans-library-design.md
Normal file
158
docs/superpowers/specs/2026-06-17-plans-library-design.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 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 (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 `.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 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 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 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: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 sample plan and makes it active.
|
||||||
|
- `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.
|
||||||
Reference in New Issue
Block a user