Compare commits

..

8 Commits

11 changed files with 496 additions and 31 deletions

View File

@@ -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".
_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
- `┣` start of a Feature's bar
@@ -56,6 +60,7 @@ _Avoid_: today marker, cursor
## 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.
- 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 **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**.

View File

@@ -17,9 +17,7 @@ Dashboard ┣━━━━━◯ 🔴 n
## 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.
Not yet built: a **library** of multiple named plans. Today a single source autosaves to localStorage.
**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.
## 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.
- 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.
- 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.
## Documentation

91
src/App.test.ts Normal file
View 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')
})
})

View File

@@ -1,14 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue'
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, resetToSample } = useMacroplan()
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>
@@ -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">
<img src="/favicon.svg" alt="" class="size-6" />
<div class="flex-1">
<h1 class="text-lg font-semibold leading-tight">Macroplan</h1>
<p class="text-xs text-base-content/60 leading-tight">
A week-granular, learning-oriented record of what we committed to deliver.
</p>
<PlanSwitcher
:plans="plans"
:active-id="activeId"
@select="selectPlan"
@new="newPlan"
@download="downloadToml"
/>
</div>
<button
class="btn btn-ghost btn-sm"
@@ -38,7 +57,14 @@ const exportRoot = ref<HTMLElement>()
>
Download
</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>
<main class="flex min-h-0 flex-1 flex-col md:flex-row">
@@ -62,6 +88,22 @@ const exportRoot = ref<HTMLElement>()
</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>

View 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'])
})
})

View 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>

View 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
})
})

View File

@@ -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<Plan | null>(null)
const lib = ref<Library>(loadLibrary())
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 }>(() => {
try {
@@ -31,20 +116,43 @@ 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 })
// Reset last-good to the target plan's current parse (null if it's broken),
// 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 {
source,
plan: computed(() => parsed.value.plan ?? lastGood.value),
error: computed(() => parsed.value.error),
resetToSample: () => {
source.value = SAMPLE_PLAN
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)
}
},
}
}

View File

@@ -2,14 +2,18 @@ import { ref } from 'vue'
type Toast = { kind: 'ok' | 'err'; text: string } | null
/** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string {
const slug = title
/** 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, '')
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`
}
/**

View 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')
})
})

View 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)
}