Skip to content

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 structural target — required to keep the future tighter in-tool loop and target-inference open (spec §5.6). Do not drop these even though nothing reads reason/target yet.
  • Follow existing store conventions: Zustand create, getState()-testable actions, tests clear state in beforeEach (mirror src/store/constraintStore.test.ts).
  • Terminology: "feature vectors" not "embeddings" (not relevant to this phase, but house rule).
  • IPA is canonical (ɡ U+0261 not ASCII g) — pack ipa/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 over crypto.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) — usePackStore Zustand store (active pack + library + actions).
  • src/test/setup.ts (modify) — add import '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
Expected: both added to 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.