Compare commits
8 Commits
35c84cf943
...
362a448848
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362a448848 | ||
|
|
e2f76195dd | ||
|
|
69932fa2bf | ||
|
|
ba5442c58f | ||
|
|
7468ebd3c9 | ||
|
|
3a012f186d | ||
|
|
1c986e417f | ||
|
|
3c32542a76 |
@@ -44,6 +44,10 @@ _Avoid_: column, period, sprint
|
|||||||
A vertical line marking the current week across the whole plan — the at-a-glance "where are we right now".
|
A vertical line marking the current week across the whole plan — the at-a-glance "where are we right now".
|
||||||
_Avoid_: today marker, cursor
|
_Avoid_: today marker, cursor
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
## Symbols
|
## Symbols
|
||||||
|
|
||||||
- `┣` start of a Feature's bar
|
- `┣` start of a Feature's bar
|
||||||
@@ -56,6 +60,7 @@ _Avoid_: today marker, cursor
|
|||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- A **Macroplan** contains a flat, author-ordered list of **Features** (typically ordered by start **Week**) and many **Milestones**. There is no grouping/workstream concept.
|
- A **Macroplan** contains a flat, author-ordered list of **Features** (typically ordered by start **Week**) and many **Milestones**. There is no grouping/workstream concept.
|
||||||
|
- 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**.
|
||||||
- A **Feature** has exactly one **Original Estimate**, zero or more **Re-estimates**, at most one **Delivery**, and at most one **Learning**.
|
- A **Feature** has exactly one **Original Estimate**, zero or more **Re-estimates**, at most one **Delivery**, and at most one **Learning**.
|
||||||
- A **Milestone** explicitly names the **Features** required by it; a Feature may be required by zero, one, or several Milestones, and a Feature may be in the plan without belonging to any Milestone.
|
- A **Milestone** explicitly names the **Features** required by it; a Feature may be required by zero, one, or several Milestones, and a Feature may be in the plan without belonging to any Milestone.
|
||||||
- On-time vs. late is judged against the **Original Estimate**, never a **Re-estimate**.
|
- On-time vs. late is judged against the **Original Estimate**, never a **Re-estimate**.
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ Dashboard ┣━━━━━◯ 🔴 n
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
Not yet built: a **library** of multiple named plans. Today a single source autosaves to localStorage.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -27,7 +25,7 @@ Not yet built: a **library** of multiple named plans. Today a single source auto
|
|||||||
- The view is a **CSS-Grid** week × feature layout with the symbol vocabulary, real status colors (🟢/🟠/🔴 with hover notes), a "now" line, sticky feature-name and week-axis panes, and a trailing **Learning** column.
|
- The view is a **CSS-Grid** week × feature layout with the symbol vocabulary, real status colors (🟢/🟠/🔴 with hover notes), a "now" line, sticky feature-name and week-axis panes, and a trailing **Learning** column.
|
||||||
- On-time vs. late is **derived** by the app against the Original Estimate — you never type "late".
|
- On-time vs. late is **derived** by the app against the Original Estimate — you never type "late".
|
||||||
- **Milestones** are vertical lines tied to an explicit list of required features.
|
- **Milestones** are vertical lines tied to an explicit list of required features.
|
||||||
- Your source **autosaves to localStorage**; **export a PNG** to share into Slack or a deck.
|
- 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.
|
||||||
- Stack: Vite + Vue 3 + DaisyUI · `smol-toml` (parse) · `html-to-image` (export). Static SPA, no backend.
|
- Stack: Vite + Vue 3 + DaisyUI · `smol-toml` (parse) · `html-to-image` (export). Static SPA, no backend.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
91
src/App.test.ts
Normal file
91
src/App.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
// downloadSource performs a real DOM/Blob download; mock it so we can assert
|
||||||
|
// the wiring without touching the filesystem. sourceFilename stays real.
|
||||||
|
vi.mock('./composables/useSourceExport', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./composables/useSourceExport')>()
|
||||||
|
return { ...actual, downloadSource: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import { downloadSource } from './composables/useSourceExport'
|
||||||
|
import { SAMPLE_PLAN } from './data/sample'
|
||||||
|
|
||||||
|
// The editor lazy-loads Shiki and the grid is heavy; neither is under test here.
|
||||||
|
const stubs = { PlanEditor: true, MacroplanGrid: true }
|
||||||
|
|
||||||
|
function mountApp() {
|
||||||
|
return mount(App, { global: { stubs } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropdown links = one <a> per plan, then "New plan" and "Download .toml".
|
||||||
|
function dropdownLinks(w: ReturnType<typeof mountApp>) {
|
||||||
|
return w.findAll('.dropdown-content li a')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
|
||||||
|
describe('App — plan library wiring', () => {
|
||||||
|
it('shows the active plan name in the header switcher', () => {
|
||||||
|
expect(mountApp().text()).toContain('Q3 — Checkout revamp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a new plan from the dropdown', async () => {
|
||||||
|
const w = mountApp()
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan + New + Download
|
||||||
|
await dropdownLinks(w)[1].trigger('click') // New plan
|
||||||
|
await nextTick()
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the confirm modal and deletes the active plan on confirm', async () => {
|
||||||
|
const w = mountApp()
|
||||||
|
await dropdownLinks(w)[1].trigger('click') // New → 2 plans
|
||||||
|
await nextTick()
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(4)
|
||||||
|
|
||||||
|
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||||
|
expect(w.find('.modal-open').exists()).toBe(true)
|
||||||
|
|
||||||
|
await w.find('.modal-action .btn-error').trigger('click') // Delete
|
||||||
|
await nextTick()
|
||||||
|
expect(w.find('.modal-open').exists()).toBe(false)
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(3) // back to 1 plan
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel dismisses the modal without deleting', async () => {
|
||||||
|
const w = mountApp()
|
||||||
|
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||||
|
expect(w.find('.modal-open').exists()).toBe(true)
|
||||||
|
|
||||||
|
const cancel = w
|
||||||
|
.findAll('.modal-action .btn')
|
||||||
|
.find((b) => !b.classes().includes('btn-error'))!
|
||||||
|
await cancel.trigger('click')
|
||||||
|
expect(w.find('.modal-open').exists()).toBe(false)
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-seeds a fresh sample when the last plan is deleted (never empty)', async () => {
|
||||||
|
const w = mountApp()
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan
|
||||||
|
|
||||||
|
await w.find('button[title="Delete this plan"]').trigger('click')
|
||||||
|
await w.find('.modal-action .btn-error').trigger('click') // Delete the only plan
|
||||||
|
await nextTick()
|
||||||
|
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded
|
||||||
|
expect(w.text()).toContain('Q3 — Checkout revamp')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wires the dropdown .toml download to downloadSource with the active source and a .toml name', async () => {
|
||||||
|
const w = mountApp()
|
||||||
|
await dropdownLinks(w)[2].trigger('click') // Download .toml (1 plan → index 2)
|
||||||
|
expect(downloadSource).toHaveBeenCalledOnce()
|
||||||
|
const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||||
|
expect(src).toBe(SAMPLE_PLAN)
|
||||||
|
expect(filename).toBe('macroplan-q3-checkout-revamp.toml')
|
||||||
|
})
|
||||||
|
})
|
||||||
56
src/App.vue
56
src/App.vue
@@ -1,14 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMacroplan } from './composables/useMacroplan'
|
import { useMacroplan } from './composables/useMacroplan'
|
||||||
import { usePngExport, exportFilename } from './composables/usePngExport'
|
import { usePngExport, exportFilename } from './composables/usePngExport'
|
||||||
|
import { sourceFilename, downloadSource } from './composables/useSourceExport'
|
||||||
import PlanEditor from './components/PlanEditor.vue'
|
import PlanEditor from './components/PlanEditor.vue'
|
||||||
import MacroplanGrid from './components/MacroplanGrid.vue'
|
import MacroplanGrid from './components/MacroplanGrid.vue'
|
||||||
|
import PlanSwitcher from './components/PlanSwitcher.vue'
|
||||||
|
|
||||||
const { source, plan, error, resetToSample } = useMacroplan()
|
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
|
||||||
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
const { busy, toast, copyPng, downloadPng } = usePngExport()
|
||||||
|
|
||||||
const exportRoot = ref<HTMLElement>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,10 +32,13 @@ const exportRoot = ref<HTMLElement>()
|
|||||||
<header class="navbar min-h-0 gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
|
<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" />
|
<img src="/favicon.svg" alt="" class="size-6" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h1 class="text-lg font-semibold leading-tight">Macroplan</h1>
|
<PlanSwitcher
|
||||||
<p class="text-xs text-base-content/60 leading-tight">
|
:plans="plans"
|
||||||
A week-granular, learning-oriented record of what we committed to deliver.
|
:active-id="activeId"
|
||||||
</p>
|
@select="selectPlan"
|
||||||
|
@new="newPlan"
|
||||||
|
@download="downloadToml"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm"
|
class="btn btn-ghost btn-sm"
|
||||||
@@ -38,7 +57,14 @@ const exportRoot = ref<HTMLElement>()
|
|||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="resetToSample">Reset to sample</button>
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:disabled="busy"
|
||||||
|
title="Delete this plan"
|
||||||
|
@click="confirmingDelete = true"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
|
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||||
@@ -62,6 +88,22 @@ const exportRoot = ref<HTMLElement>()
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</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 v-if="toast" class="toast toast-end">
|
||||||
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
|
<div class="alert" :class="toast.kind === 'ok' ? 'alert-success' : 'alert-error'">
|
||||||
<span>{{ toast.text }}</span>
|
<span>{{ toast.text }}</span>
|
||||||
|
|||||||
33
src/components/PlanSwitcher.test.ts
Normal file
33
src/components/PlanSwitcher.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// @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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/components/PlanSwitcher.vue
Normal file
41
src/components/PlanSwitcher.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<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>
|
||||||
113
src/composables/useMacroplan.test.ts
Normal file
113
src/composables/useMacroplan.test.ts
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,19 +1,104 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, shallowRef, computed, watch } from 'vue'
|
||||||
import { parseMacroplan } from '../model/parse'
|
import { parseMacroplan } from '../model/parse'
|
||||||
import { buildPlan } from '../model/plan'
|
import { buildPlan } from '../model/plan'
|
||||||
import type { Plan } from '../model/types'
|
import type { Plan } from '../model/types'
|
||||||
import { SAMPLE_PLAN } from '../data/sample'
|
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
|
* Owns the Library — the collection of named Macroplans in localStorage
|
||||||
* localStorage), the parsed Plan, and the current parse error. The last
|
* (ADR-0002). Exposes the active plan's authoring state (source / parsed plan /
|
||||||
* successfully-parsed Plan keeps rendering through transient typos (F3).
|
* error, with the last good render kept through transient typos, F3) plus the
|
||||||
|
* switch / create / delete operations over the library.
|
||||||
*/
|
*/
|
||||||
export function useMacroplan() {
|
export function useMacroplan() {
|
||||||
const source = ref(localStorage.getItem(STORAGE_KEY) ?? SAMPLE_PLAN)
|
const lib = ref<Library>(loadLibrary())
|
||||||
const lastGood = ref<Plan | null>(null)
|
const lastGood = shallowRef<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 }>(() => {
|
const parsed = computed<{ plan: Plan | null; error: string | null }>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -31,20 +116,43 @@ export function useMacroplan() {
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(source, (v) => {
|
// Persist the whole library on any change (best-effort, like the old autosave).
|
||||||
try {
|
watch(lib, (l) => save(l), { deep: true })
|
||||||
localStorage.setItem(STORAGE_KEY, v)
|
|
||||||
} catch {
|
// Reset last-good to the target plan's current parse (null if it's broken),
|
||||||
/* localStorage may be full or blocked — autosave is best-effort */
|
// so switching to a broken plan shows its own error/empty state rather than
|
||||||
|
// the previous plan's grid.
|
||||||
|
function switchTo(id: string): void {
|
||||||
|
lib.value.activeId = id
|
||||||
|
lastGood.value = parsed.value.plan
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
plan: computed(() => parsed.value.plan ?? lastGood.value),
|
||||||
error: computed(() => parsed.value.error),
|
error: computed(() => parsed.value.error),
|
||||||
resetToSample: () => {
|
plans: computed(() => lib.value.plans.map((p) => ({ id: p.id, name: p.name }))),
|
||||||
source.value = SAMPLE_PLAN
|
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)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import { ref } from 'vue'
|
|||||||
|
|
||||||
type Toast = { kind: 'ok' | 'err'; text: string } | null
|
type Toast = { kind: 'ok' | 'err'; text: string } | null
|
||||||
|
|
||||||
/** Slugified, stable download name derived from the plan title. */
|
/** Lowercase, dash-collapsed slug of a plan title — no extension, no fallback. */
|
||||||
export function exportFilename(title: string): string {
|
export function slugify(title: string): string {
|
||||||
const slug = title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
return `macroplan-${slug || 'plan'}.png`
|
}
|
||||||
|
|
||||||
|
/** Slugified, stable download name derived from the plan title. */
|
||||||
|
export function exportFilename(title: string): string {
|
||||||
|
return `macroplan-${slugify(title) || 'plan'}.png`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
src/composables/useSourceExport.test.ts
Normal file
13
src/composables/useSourceExport.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
17
src/composables/useSourceExport.ts
Normal file
17
src/composables/useSourceExport.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user