Therapy Pack — Phase 1: Pack Data Model + IndexedDB "My Packs" Store — 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: Build the local-first persistence + state foundation for Therapy Packs — a typed pack model, an IndexedDB-backed "My Packs" library (many packs, revisitable), and a Zustand store the rest of the 7.0.0 UI consumes.
Architecture: Three layers. (1) Pure TypeScript types + a createEmptyPack factory. (2) packDb — thin async IndexedDB CRUD via idb-keyval in a named store. (3) usePackStore — a Zustand store holding the active pack + a library summary list, mutating the active pack and persisting each change to packDb. UI (later phases) reads/acts only through the store. No accounts, no server.
Tech Stack: TypeScript, React 19, Zustand ^5 (already a dep), idb-keyval (new dep), Vitest + happy-dom, fake-indexeddb (new devDep, polyfills IndexedDB in tests).
Global Constraints¶
- Local-first only in 7.0.0 — no server persistence, no accounts (spec §3, §5.6).
- Pack content must be non-patient-specific (spec §5.6) — no field invites patient identifiers.
- Store schema carries
schemaVersion(migration guard), per-item provenance (sourceTool,reason), and a structuraltarget— required to keep the future tighter in-tool loop and target-inference open (spec §5.6). Do not drop these even though nothing readsreason/targetyet. - Follow existing store conventions: Zustand
create,getState()-testable actions, tests clear state inbeforeEach(mirrorsrc/store/constraintStore.test.ts). - Terminology: "feature vectors" not "embeddings" (not relevant to this phase, but house rule).
- IPA is canonical (
ɡU+0261 not ASCIIg) — packipa/phone fields store whatever the source API returns; do not transform here.
File Structure¶
src/types/therapyPack.ts(create) — all pack types,PACK_SCHEMA_VERSION,createEmptyPack,summarize.src/lib/id.ts(create) —newId()wrapper overcrypto.randomUUID()(one place to stub/replace).src/lib/packDb.ts(create) — IndexedDB CRUD (putPack,getPack,getAllPacks,deletePack).src/lib/packMigrations.ts(create) —migratePack(raw)version upgrade shim.src/store/packStore.ts(create) —usePackStoreZustand store (active pack + library + actions).src/test/setup.ts(modify) — addimport 'fake-indexeddb/auto';so IndexedDB exists in tests.- Tests:
src/types/therapyPack.test.ts,src/lib/packDb.test.ts,src/lib/packMigrations.test.ts,src/store/packStore.test.ts.
Task 1: Pack types + factory¶
Files:
- Create: src/types/therapyPack.ts
- Test: src/types/therapyPack.test.ts
Interfaces:
- Consumes: nothing.
- Produces: PACK_SCHEMA_VERSION: number; types PackSourceTool, PackItemProvenance, PackWord, PackContrast, PackSentence, PackItem, TargetSpec, TherapyPack, PackSummary; createEmptyPack(id: string, now: number): TherapyPack; summarize(pack: TherapyPack): PackSummary.
- [ ] Step 1: Write the failing test
// src/types/therapyPack.test.ts
import { describe, it, expect } from 'vitest';
import { createEmptyPack, summarize, PACK_SCHEMA_VERSION } from './therapyPack';
describe('createEmptyPack', () => {
it('creates an empty, versioned pack with the given id and timestamps', () => {
const p = createEmptyPack('pack-1', 1000);
expect(p).toMatchObject({
schemaVersion: PACK_SCHEMA_VERSION,
id: 'pack-1',
title: 'Untitled pack',
target: null,
words: [],
contrasts: [],
sentences: [],
notes: '',
createdAt: 1000,
updatedAt: 1000,
});
});
});
describe('summarize', () => {
it('reports id, title, updatedAt and section counts', () => {
const p = createEmptyPack('pack-2', 5);
p.title = 'Initial /r/';
p.words.push({
kind: 'word', id: 'w1', word: 'rocket',
provenance: { sourceTool: 'guided', addedAt: 5 },
});
p.updatedAt = 9;
expect(summarize(p)).toEqual({
id: 'pack-2',
title: 'Initial /r/',
updatedAt: 9,
counts: { words: 1, contrasts: 0, sentences: 0 },
});
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run src/types/therapyPack.test.ts
Expected: FAIL — cannot resolve ./therapyPack.
- [ ] Step 3: Write minimal implementation
// src/types/therapyPack.ts
export const PACK_SCHEMA_VERSION = 1;
export type PackSourceTool =
| 'guided' | 'prebuilt' | 'wordLists' | 'contrastive'
| 'sentences' | 'lookup' | 'textAnalysis' | 'manual';
export interface PackItemProvenance {
sourceTool: PackSourceTool;
addedAt: number; // epoch ms
reason?: string; // why-included; kept for future target-inference
}
export interface PackWord {
kind: 'word';
id: string;
word: string;
ipa?: string;
hasImage?: boolean;
provenance: PackItemProvenance;
}
export interface PackContrast {
kind: 'contrast';
id: string;
a: string;
b: string;
label?: string; // e.g. "r vs w (gliding)"
provenance: PackItemProvenance;
}
export interface PackSentence {
kind: 'sentence';
id: string;
text: string;
targets: string[]; // surface words to highlight
provenance: PackItemProvenance;
}
export type PackItem = PackWord | PackContrast | PackSentence;
export interface TargetSpec {
mode: 'sound' | 'contrast' | 'process';
phones: string[];
position?: 'initial' | 'medial' | 'final' | 'any';
processPreset?: string;
level?: string;
}
export interface TherapyPack {
schemaVersion: number;
id: string;
title: string;
target: TargetSpec | null;
words: PackWord[];
contrasts: PackContrast[];
sentences: PackSentence[];
notes: string;
createdAt: number;
updatedAt: number;
}
export interface PackSummary {
id: string;
title: string;
updatedAt: number;
counts: { words: number; contrasts: number; sentences: number };
}
export function createEmptyPack(id: string, now: number): TherapyPack {
return {
schemaVersion: PACK_SCHEMA_VERSION,
id,
title: 'Untitled pack',
target: null,
words: [],
contrasts: [],
sentences: [],
notes: '',
createdAt: now,
updatedAt: now,
};
}
export function summarize(pack: TherapyPack): PackSummary {
return {
id: pack.id,
title: pack.title,
updatedAt: pack.updatedAt,
counts: {
words: pack.words.length,
contrasts: pack.contrasts.length,
sentences: pack.sentences.length,
},
};
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run src/types/therapyPack.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add src/types/therapyPack.ts src/types/therapyPack.test.ts
git commit -m "feat(packs): pack data model + factory (PHON-170)"
Task 2: newId helper + IndexedDB CRUD (packDb)¶
Files:
- Create: src/lib/id.ts, src/lib/packDb.ts
- Modify: src/test/setup.ts (add IndexedDB polyfill)
- Test: src/lib/packDb.test.ts
Interfaces:
- Consumes: TherapyPack (Task 1).
- Produces: newId(): string; putPack(pack): Promise<void>; getPack(id): Promise<TherapyPack | undefined>; getAllPacks(): Promise<TherapyPack[]>; deletePack(id): Promise<void>.
- [ ] Step 1: Install dependencies
Run:
npm install idb-keyval@^6
npm install -D fake-indexeddb@^6
packages/web/frontend/package.json.
- [ ] Step 2: Add the IndexedDB polyfill to the test setup
Edit src/test/setup.ts — add as the first line:
import 'fake-indexeddb/auto';
(Leave the existing afterEach(cleanup) block unchanged.)
- [ ] Step 3: Write the failing test
// src/lib/packDb.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { putPack, getPack, getAllPacks, deletePack } from './packDb';
import { createEmptyPack } from '../types/therapyPack';
async function clearAll() {
const all = await getAllPacks();
await Promise.all(all.map((p) => deletePack(p.id)));
}
describe('packDb', () => {
beforeEach(clearAll);
it('round-trips a pack by id', async () => {
const p = createEmptyPack('a', 1);
await putPack(p);
expect(await getPack('a')).toEqual(p);
});
it('returns undefined for a missing id', async () => {
expect(await getPack('nope')).toBeUndefined();
});
it('lists all stored packs', async () => {
await putPack(createEmptyPack('a', 1));
await putPack(createEmptyPack('b', 2));
const ids = (await getAllPacks()).map((p) => p.id).sort();
expect(ids).toEqual(['a', 'b']);
});
it('deletes a pack', async () => {
await putPack(createEmptyPack('a', 1));
await deletePack('a');
expect(await getPack('a')).toBeUndefined();
});
});
- [ ] Step 4: Run test to verify it fails
Run: npx vitest run src/lib/packDb.test.ts
Expected: FAIL — cannot resolve ./packDb.
- [ ] Step 5: Write minimal implementation
// src/lib/id.ts
/** Single indirection over crypto.randomUUID so ids are easy to stub/replace. */
export const newId = (): string => crypto.randomUUID();
// src/lib/packDb.ts
/**
* IndexedDB persistence for Therapy Packs — a named idb-keyval store,
* one record per pack keyed by pack id. Local-first; no server (spec §5.6).
*/
import { get, set, del, keys, createStore } from 'idb-keyval';
import type { TherapyPack } from '../types/therapyPack';
const store = createStore('phonolex-packs', 'packs');
export async function putPack(pack: TherapyPack): Promise<void> {
await set(pack.id, pack, store);
}
export async function getPack(id: string): Promise<TherapyPack | undefined> {
return get<TherapyPack>(id, store);
}
export async function getAllPacks(): Promise<TherapyPack[]> {
const ids = await keys(store);
const packs = await Promise.all(
ids.map((id) => get<TherapyPack>(id as string, store)),
);
return packs.filter((p): p is TherapyPack => p !== undefined);
}
export async function deletePack(id: string): Promise<void> {
await del(id, store);
}
- [ ] Step 6: Run test to verify it passes
Run: npx vitest run src/lib/packDb.test.ts
Expected: PASS (4 tests).
- [ ] Step 7: Commit
git add package.json package-lock.json src/lib/id.ts src/lib/packDb.ts src/lib/packDb.test.ts src/test/setup.ts
git commit -m "feat(packs): IndexedDB pack persistence via idb-keyval (PHON-170)"
Task 3: Schema migration shim¶
Files:
- Create: src/lib/packMigrations.ts
- Test: src/lib/packMigrations.test.ts
Interfaces:
- Consumes: TherapyPack, PACK_SCHEMA_VERSION (Task 1).
- Produces: migratePack(raw: TherapyPack): TherapyPack — returns a pack whose schemaVersion === PACK_SCHEMA_VERSION.
- [ ] Step 1: Write the failing test
// src/lib/packMigrations.test.ts
import { describe, it, expect } from 'vitest';
import { migratePack } from './packMigrations';
import { createEmptyPack, PACK_SCHEMA_VERSION } from '../types/therapyPack';
describe('migratePack', () => {
it('passes through a current-version pack unchanged', () => {
const p = createEmptyPack('a', 1);
expect(migratePack(p)).toEqual(p);
});
it('stamps the current schemaVersion on an older pack', () => {
const old = { ...createEmptyPack('a', 1), schemaVersion: 0 };
expect(migratePack(old).schemaVersion).toBe(PACK_SCHEMA_VERSION);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run src/lib/packMigrations.test.ts
Expected: FAIL — cannot resolve ./packMigrations.
- [ ] Step 3: Write minimal implementation
// src/lib/packMigrations.ts
/**
* Forward-only pack migrations. Each future schema bump adds an
* `if (pack.schemaVersion < N) { ...transform...; pack.schemaVersion = N }`
* block here. Today only v1 exists, so we normalise the version stamp.
*/
import { PACK_SCHEMA_VERSION, type TherapyPack } from '../types/therapyPack';
export function migratePack(raw: TherapyPack): TherapyPack {
let pack = raw;
// (future version steps go here, in ascending order)
if (pack.schemaVersion !== PACK_SCHEMA_VERSION) {
pack = { ...pack, schemaVersion: PACK_SCHEMA_VERSION };
}
return pack;
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run src/lib/packMigrations.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add src/lib/packMigrations.ts src/lib/packMigrations.test.ts
git commit -m "feat(packs): schema migration shim (PHON-170)"
Task 4: usePackStore — active pack + My Packs library¶
Files:
- Create: src/store/packStore.ts
- Test: src/store/packStore.test.ts
Interfaces:
- Consumes: createEmptyPack, summarize, types (Task 1); newId (Task 2); packDb (Task 2); migratePack (Task 3).
- Produces: usePackStore with state { activePack: TherapyPack | null; library: PackSummary[] } and actions hydrate(), newPack(target?), loadPack(id), duplicatePack(id), deletePack(id), startOver(), setTitle(t), setNotes(n), addItem(item), removeItem(id), refreshLibrary().
Design notes for the implementer:
- Every mutation updates activePack.updatedAt, persists via packDb.putPack, and refreshes library. Persistence is fire-and-forget (void packDb.putPack(...)) — IndexedDB writes are async and cheap; debouncing text fields is a later optimization, not needed now.
- addItem routes by item.kind into words / contrasts / sentences.
- removeItem(id) removes a matching item from whichever section holds it.
- Tests stub time and ids by passing explicit values where the API allows, and otherwise assert on structure/among-set membership rather than exact ids.
- [ ] Step 1: Write the failing test
// src/store/packStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { usePackStore } from './packStore';
import { getAllPacks, deletePack } from '../lib/packDb';
import type { PackWord } from '../types/therapyPack';
const store = usePackStore;
async function clearDb() {
const all = await getAllPacks();
await Promise.all(all.map((p) => deletePack(p.id)));
}
const rocket: PackWord = {
kind: 'word', id: 'w-rocket', word: 'rocket',
provenance: { sourceTool: 'guided', addedAt: 1 },
};
describe('usePackStore', () => {
beforeEach(async () => {
await clearDb();
store.setState({ activePack: null, library: [] });
});
it('newPack creates and persists an active pack', async () => {
store.getState().newPack();
const active = store.getState().activePack!;
expect(active).not.toBeNull();
expect(await getAllPacks()).toHaveLength(1);
expect(store.getState().library.map((s) => s.id)).toContain(active.id);
});
it('addItem routes a word into the words section and persists', async () => {
store.getState().newPack();
store.getState().addItem(rocket);
expect(store.getState().activePack!.words).toEqual([rocket]);
const persisted = (await getAllPacks())[0];
expect(persisted.words).toEqual([rocket]);
});
it('removeItem drops the item by id', () => {
store.getState().newPack();
store.getState().addItem(rocket);
store.getState().removeItem('w-rocket');
expect(store.getState().activePack!.words).toEqual([]);
});
it('setTitle updates title and library summary', () => {
store.getState().newPack();
store.getState().setTitle('Initial /r/');
expect(store.getState().activePack!.title).toBe('Initial /r/');
const id = store.getState().activePack!.id;
expect(store.getState().library.find((s) => s.id === id)!.title).toBe('Initial /r/');
});
it('hydrate loads saved packs into the library', async () => {
store.getState().newPack();
store.getState().setTitle('Saved one');
store.setState({ activePack: null, library: [] });
await store.getState().hydrate();
expect(store.getState().library.map((s) => s.title)).toContain('Saved one');
});
it('loadPack makes a saved pack active', async () => {
store.getState().newPack();
const id = store.getState().activePack!.id;
store.setState({ activePack: null });
await store.getState().loadPack(id);
expect(store.getState().activePack!.id).toBe(id);
});
it('duplicatePack creates a distinct copy', async () => {
store.getState().newPack();
store.getState().setTitle('Original');
const id = store.getState().activePack!.id;
await store.getState().duplicatePack(id);
const active = store.getState().activePack!;
expect(active.id).not.toBe(id);
expect(active.title).toBe('Original (copy)');
expect(await getAllPacks()).toHaveLength(2);
});
it('deletePack removes it and clears active if it was active', async () => {
store.getState().newPack();
const id = store.getState().activePack!.id;
await store.getState().deletePack(id);
expect(store.getState().activePack).toBeNull();
expect(await getAllPacks()).toHaveLength(0);
});
it('startOver clears the active pack without deleting saved packs', async () => {
store.getState().newPack();
store.getState().startOver();
expect(store.getState().activePack).toBeNull();
expect(await getAllPacks()).toHaveLength(1);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run src/store/packStore.test.ts
Expected: FAIL — cannot resolve ./packStore.
- [ ] Step 3: Write minimal implementation
// src/store/packStore.ts
/**
* Therapy Pack store — the single surface the 7.0.0 UI uses to read and
* mutate the active pack and browse the saved "My Packs" library.
* Persists every change to IndexedDB (packDb). Local-first (spec §5.6).
*/
import { create } from 'zustand';
import {
createEmptyPack, summarize,
type TherapyPack, type PackItem, type PackSummary, type TargetSpec,
} from '../types/therapyPack';
import { newId } from '../lib/id';
import * as packDb from '../lib/packDb';
import { migratePack } from '../lib/packMigrations';
interface PackState {
activePack: TherapyPack | null;
library: PackSummary[];
hydrate: () => Promise<void>;
refreshLibrary: () => Promise<void>;
newPack: (target?: TargetSpec | null) => void;
loadPack: (id: string) => Promise<void>;
duplicatePack: (id: string) => Promise<void>;
deletePack: (id: string) => Promise<void>;
startOver: () => void;
setTitle: (title: string) => void;
setNotes: (notes: string) => void;
addItem: (item: PackItem) => void;
removeItem: (id: string) => void;
}
export const usePackStore = create<PackState>((set, get) => {
/**
* Set the active pack + its library summary SYNCHRONOUSLY (so UI and tests
* see the change immediately), bump updatedAt, and persist in the background.
* refreshLibrary() is used elsewhere to re-sync the list from IndexedDB.
*/
function commit(next: TherapyPack) {
const stamped = { ...next, updatedAt: Date.now() };
const others = get().library.filter((s) => s.id !== stamped.id);
const library = [summarize(stamped), ...others].sort(
(a, b) => b.updatedAt - a.updatedAt,
);
set({ activePack: stamped, library });
void packDb.putPack(stamped); // persist in background
}
return {
activePack: null,
library: [],
async refreshLibrary() {
const packs = await packDb.getAllPacks();
set({
library: packs
.map(summarize)
.sort((a, b) => b.updatedAt - a.updatedAt),
});
},
async hydrate() {
await get().refreshLibrary();
},
newPack(target = null) {
const pack = createEmptyPack(newId(), Date.now());
pack.target = target;
commit(pack);
},
async loadPack(id) {
const raw = await packDb.getPack(id);
if (raw) set({ activePack: migratePack(raw) });
},
async duplicatePack(id) {
const raw = await packDb.getPack(id);
if (!raw) return;
const src = migratePack(raw);
const copy: TherapyPack = {
...src,
id: newId(),
title: `${src.title} (copy)`,
createdAt: Date.now(),
updatedAt: Date.now(),
};
commit(copy);
},
async deletePack(id) {
await packDb.deletePack(id);
if (get().activePack?.id === id) set({ activePack: null });
await get().refreshLibrary();
},
startOver() {
set({ activePack: null });
},
setTitle(title) {
const p = get().activePack;
if (p) commit({ ...p, title });
},
setNotes(notes) {
const p = get().activePack;
if (p) commit({ ...p, notes });
},
addItem(item) {
const p = get().activePack;
if (!p) return;
if (item.kind === 'word') commit({ ...p, words: [...p.words, item] });
else if (item.kind === 'contrast') commit({ ...p, contrasts: [...p.contrasts, item] });
else commit({ ...p, sentences: [...p.sentences, item] });
},
removeItem(id) {
const p = get().activePack;
if (!p) return;
commit({
...p,
words: p.words.filter((w) => w.id !== id),
contrasts: p.contrasts.filter((c) => c.id !== id),
sentences: p.sentences.filter((s) => s.id !== id),
});
},
};
});
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run src/store/packStore.test.ts
Expected: PASS (9 tests). Note: commit updates activePack + library synchronously, so those assertions are deterministic. The getAllPacks() DB assertions await their own read, issued after the synchronous action created the putPack transaction — under fake-indexeddb IndexedDB transactions run FIFO, so the write completes before the read.
- [ ] Step 5: Commit
git add src/store/packStore.ts src/store/packStore.test.ts
git commit -m "feat(packs): usePackStore — active pack + My Packs library (PHON-170)"
Task 5: Full-suite green + type-check gate¶
Files: none (verification only).
- [ ] Step 1: Run the full frontend test suite
Run: npm test -- --run
Expected: all tests pass, including the four new files.
- [ ] Step 2: Type-check
Run: npm run type-check
Expected: no errors.
- [ ] Step 3: Lint
Run: npm run lint
Expected: no new errors.
- [ ] Step 4: Commit any fixups (only if steps 1–3 required changes)
git add -A
git commit -m "chore(packs): phase-1 suite green (type-check + lint) (PHON-170)"
Self-Review¶
Spec coverage (this phase = spec §5.6 + the data-model half of §11 phase 1):
- Local-first IndexedDB store → Task 2. ✓
- Many packs / My Packs library (list/open/rename-via-setTitle/duplicate/delete) → Task 4. ✓
- Start over / new pack → Task 4 (startOver, newPack). ✓
- schemaVersion migration guard → Task 3. ✓
- Per-item provenance + structural target retained → Task 1 types; carried through store unchanged. ✓
- Non-patient-specific (no identifier fields) → Task 1 types contain none. ✓
- (Deferred to Phase-1b Editor plan: the sectioned-workspace UI, remove/replace/reorder UI, autosave indicator. Reorder action itself is not needed until the UI exists and is intentionally omitted here — YAGNI; add in the Editor plan.)
Placeholder scan: none — every step has runnable code/commands.
Type consistency: createEmptyPack(id, now), summarize(pack), newId(), putPack/getPack/getAllPacks/deletePack, migratePack(raw), and the usePackStore action names are used identically across tasks.
Execution Handoff¶
This is Phase 1 of six (spec §11). The remaining phases each get their own plan, authored after this one lands: 1b Editor sectioned-workspace UI (consumes usePackStore), 2 export renderer, 3 front page + resolver + guided seeding, 4 pre-built packs, 5 tools "Add to Editor", 6 IA/nav + 7.0.0 bump.