# PTV11 Document Capture & Storage — Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let users capture/upload PTV11 documents per Erstgespräch, stored locally in IndexedDB, viewable in-app. **Architecture:** Dedicated IndexedDB database (`tpf-dokumente`) for blob storage, separate from PGlite. New `DokumentListe` component rendered inside `ErstgespraechCard`. No SQL migrations needed. **Tech Stack:** IndexedDB (raw API), ``, `URL.createObjectURL()`, React, Tailwind CSS, Lucide icons. --- ## File Structure | Action | Path | Responsibility | |--------|------|----------------| | Create | `src/shared/hooks/dokument-store.ts` | IndexedDB wrapper: open DB, CRUD for document blobs | | Create | `src/shared/hooks/dokument-store.test.ts` | Unit tests for the store (using fake-indexeddb) | | Create | `src/features/prozess/components/dokument-liste.tsx` | UI: upload button, thumbnail grid, delete, full-size view | | Modify | `src/features/prozess/components/process-stepper.tsx:184-207` | Render `DokumentListe` inside `ErstgespraechCard` | | Modify | `src/features/prozess/hooks.ts:208-210` | `deleteErstgespraech` also deletes associated documents | | Modify | `src/features/kontakte/hooks.ts:131-137` | `deleteAllData` also calls `deleteAllDokumente()` | --- ## Chunk 1: Storage Layer ### Task 1: Install fake-indexeddb for testing **Files:** - Modify: `package.json` - [ ] **Step 1: Install dev dependency** ```bash bun add -d fake-indexeddb ``` - [ ] **Step 2: Commit** ```bash git add package.json bun.lock git commit -m "add fake-indexeddb for IndexedDB unit tests" ``` --- ### Task 2: Write dokument-store tests **Files:** - Create: `src/shared/hooks/dokument-store.test.ts` - [ ] **Step 1: Write tests** ```ts import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { deleteAllDokumente, deleteDokument, deleteDokumenteForSprechstunde, getDokumente, saveDokument, } from "./dokument-store"; function makeFile(name: string, content = "test") { return new File([content], name, { type: "image/jpeg" }); } describe("dokument-store", () => { beforeEach(async () => { await deleteAllDokumente(); }); afterEach(async () => { await deleteAllDokumente(); }); it("saves and retrieves a document", async () => { const id = await saveDokument(1, makeFile("ptv11.jpg")); expect(id).toBeGreaterThan(0); const docs = await getDokumente(1); expect(docs).toHaveLength(1); expect(docs[0].name).toBe("ptv11.jpg"); expect(docs[0].mimeType).toBe("image/jpeg"); expect(docs[0].sprechstundeId).toBe(1); expect(docs[0].blob).toBeInstanceOf(Blob); expect(docs[0].erstelltAm).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it("retrieves only documents for the given sprechstundeId", async () => { await saveDokument(1, makeFile("a.jpg")); await saveDokument(2, makeFile("b.jpg")); const docs1 = await getDokumente(1); const docs2 = await getDokumente(2); expect(docs1).toHaveLength(1); expect(docs2).toHaveLength(1); expect(docs1[0].name).toBe("a.jpg"); expect(docs2[0].name).toBe("b.jpg"); }); it("deletes a single document by id", async () => { const id = await saveDokument(1, makeFile("a.jpg")); await deleteDokument(id); const docs = await getDokumente(1); expect(docs).toHaveLength(0); }); it("deletes all documents for a sprechstunde", async () => { await saveDokument(1, makeFile("a.jpg")); await saveDokument(1, makeFile("b.jpg")); await saveDokument(2, makeFile("c.jpg")); await deleteDokumenteForSprechstunde(1); expect(await getDokumente(1)).toHaveLength(0); expect(await getDokumente(2)).toHaveLength(1); }); it("deletes all documents across all sprechstunden", async () => { await saveDokument(1, makeFile("a.jpg")); await saveDokument(2, makeFile("b.jpg")); await deleteAllDokumente(); expect(await getDokumente(1)).toHaveLength(0); expect(await getDokumente(2)).toHaveLength(0); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npx vitest run src/shared/hooks/dokument-store.test.ts ``` Expected: FAIL — module `./dokument-store` not found. - [ ] **Step 3: Commit** ```bash git add src/shared/hooks/dokument-store.test.ts git commit -m "add failing tests for dokument-store IndexedDB wrapper" ``` --- ### Task 3: Implement dokument-store **Files:** - Create: `src/shared/hooks/dokument-store.ts` - [ ] **Step 1: Write implementation** ```ts const DB_NAME = "tpf-dokumente"; const DB_VERSION = 1; const STORE_NAME = "dokumente"; export interface Dokument { id: number; sprechstundeId: number; name: string; mimeType: string; blob: Blob; erstelltAm: string; } function openDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true, }); store.createIndex("sprechstundeId", "sprechstundeId", { unique: false, }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function todayISO() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } export async function saveDokument( sprechstundeId: number, file: File, ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const record: Omit = { sprechstundeId, name: file.name, mimeType: file.type, blob: file, erstelltAm: todayISO(), }; const request = store.add(record); request.onsuccess = () => resolve(request.result as number); request.onerror = () => reject(request.error); }); } export async function getDokumente( sprechstundeId: number, ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); const index = store.index("sprechstundeId"); const request = index.getAll(sprechstundeId); request.onsuccess = () => resolve(request.result as Dokument[]); request.onerror = () => reject(request.error); }); } export async function deleteDokument(id: number): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } export async function deleteDokumenteForSprechstunde( sprechstundeId: number, ): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const index = store.index("sprechstundeId"); const request = index.openCursor(sprechstundeId); request.onsuccess = () => { const cursor = request.result; if (cursor) { cursor.delete(); cursor.continue(); } }; tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } export async function deleteAllDokumente(): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); const request = store.clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } ``` - [ ] **Step 2: Run tests to verify they pass** ```bash npx vitest run src/shared/hooks/dokument-store.test.ts ``` Expected: all 5 tests PASS. - [ ] **Step 3: Commit** ```bash git add src/shared/hooks/dokument-store.ts git commit -m "implement dokument-store IndexedDB wrapper" ``` --- ## Chunk 2: UI Component ### Task 4: Create DokumentListe component **Files:** - Create: `src/features/prozess/components/dokument-liste.tsx` - [ ] **Step 1: Write component** ```tsx import { FileText, Plus, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/shared/components/ui/button"; import { type Dokument, deleteDokument, getDokumente, saveDokument, } from "@/shared/hooks/dokument-store"; interface DokumentListeProps { sprechstundeId: number; } export function DokumentListe({ sprechstundeId }: DokumentListeProps) { const [dokumente, setDokumente] = useState([]); const [viewDokument, setViewDokument] = useState(null); const refresh = useCallback(async () => { const docs = await getDokumente(sprechstundeId); setDokumente(docs); }, [sprechstundeId]); useEffect(() => { refresh(); }, [refresh]); const handleFiles = async (files: FileList | null) => { if (!files) return; for (const file of files) { await saveDokument(sprechstundeId, file); } await refresh(); }; const handleDelete = async (id: number) => { if (!window.confirm("Dokument wirklich löschen?")) return; await deleteDokument(id); await refresh(); }; const handleView = (dok: Dokument) => { if (dok.mimeType === "application/pdf") { const url = URL.createObjectURL(dok.blob); window.open(url, "_blank"); setTimeout(() => URL.revokeObjectURL(url), 60_000); } else { setViewDokument(dok); } }; return (

Dokumente

{dokumente.length > 0 && (
{dokumente.map((dok) => ( handleView(dok)} onDelete={() => handleDelete(dok.id)} /> ))}
)} {viewDokument && ( setViewDokument(null)} /> )}
); } function DokumentThumbnail({ dokument, onView, onDelete, }: { dokument: Dokument; onView: () => void; onDelete: () => void; }) { const [url, setUrl] = useState(null); const isImage = dokument.mimeType.startsWith("image/"); useEffect(() => { if (isImage) { const objectUrl = URL.createObjectURL(dokument.blob); setUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); } }, [dokument.blob, isImage]); return (
); } function ImageModal({ dokument, onClose, }: { dokument: Dokument; onClose: () => void; }) { const [url, setUrl] = useState(null); useEffect(() => { const objectUrl = URL.createObjectURL(dokument.blob); setUrl(objectUrl); return () => URL.revokeObjectURL(objectUrl); }, [dokument.blob]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); return (
{url && ( {dokument.name} e.stopPropagation()} /> )}
); } ``` - [ ] **Step 2: Verify no type errors** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add src/features/prozess/components/dokument-liste.tsx git commit -m "add DokumentListe component: upload, thumbnails, image modal, PDF viewer" ``` --- ### Task 5: Integrate DokumentListe into ErstgespraechCard **Files:** - Modify: `src/features/prozess/components/process-stepper.tsx:1-4,184-207` - [ ] **Step 1: Add import** At the top of `process-stepper.tsx`, add the import after the existing imports (after line 21): ```ts import { DokumentListe } from "./dokument-liste"; ``` - [ ] **Step 2: Add DokumentListe inside ErstgespraechCard** In the `ErstgespraechCard` function, inside the `{expanded && ...}` block (line 185), add `` between the `` and the editing section. The block at lines 185-207 becomes: ```tsx
{editing ? ( ``` This inserts a single line after line 186. - [ ] **Step 3: Verify build** ```bash npx tsc --noEmit && npx vite build 2>&1 | tail -5 ``` Expected: no errors, build succeeds. - [ ] **Step 4: Commit** ```bash git add src/features/prozess/components/process-stepper.tsx git commit -m "integrate DokumentListe into ErstgespraechCard expanded view" ``` --- ## Chunk 3: Cleanup Integration & Verification ### Task 6: Wire up document cleanup on delete **Files:** - Modify: `src/features/prozess/hooks.ts:208-210` - Modify: `src/features/kontakte/hooks.ts:131-137` - [ ] **Step 1: Update deleteErstgespraech** In `src/features/prozess/hooks.ts`, add import at top: ```ts import { deleteDokumenteForSprechstunde } from "@/shared/hooks/dokument-store"; ``` Replace the `deleteErstgespraech` function (lines 208-210): ```ts export async function deleteErstgespraech(id: number) { await deleteDokumenteForSprechstunde(id); await dbExec("DELETE FROM sprechstunde WHERE id = $1", [id]); } ``` - [ ] **Step 2: Update deleteAllData** In `src/features/kontakte/hooks.ts`, add import at top: ```ts import { deleteAllDokumente } from "@/shared/hooks/dokument-store"; ``` Add `await deleteAllDokumente();` as the first line inside `deleteAllData()`: ```ts export async function deleteAllData() { await deleteAllDokumente(); await dbExec("DELETE FROM sitzung"); await dbExec("DELETE FROM kontakt"); await dbExec("DELETE FROM sprechstunde"); await dbExec("DELETE FROM therapeut"); await dbExec("DELETE FROM nutzer"); } ``` - [ ] **Step 3: Verify types and tests** ```bash npx tsc --noEmit && npx vitest run ``` Expected: no type errors, all tests pass. - [ ] **Step 4: Commit** ```bash git add src/features/prozess/hooks.ts src/features/kontakte/hooks.ts git commit -m "wire up document cleanup in deleteErstgespraech, deleteAllData" ``` --- ### Task 7: Final verification - [ ] **Step 1: Lint** ```bash npx biome check src/shared/hooks/dokument-store.ts src/shared/hooks/dokument-store.test.ts src/features/prozess/components/dokument-liste.tsx src/features/prozess/components/process-stepper.tsx src/features/prozess/hooks.ts src/features/kontakte/hooks.ts ``` If issues, run `npx biome check --write` on affected files, then commit the fix. - [ ] **Step 2: Full build** ```bash npx vite build 2>&1 | tail -5 ``` Expected: build succeeds with PWA output. - [ ] **Step 3: Full test suite** ```bash npx vitest run ``` Expected: all tests pass (existing + 5 new dokument-store tests). - [ ] **Step 4: Deploy and post Gitea comment** ```bash bash scripts/deploy.sh serve ``` Post comment on issue #2: ```bash echo '## Sub-project 4: Document Scanning Users can now capture/upload PTV11 documents per Erstgespräch. - IndexedDB-based blob storage (separate from PGlite) - Upload via native file picker (triggers document scanner on mobile) - Image thumbnails with full-size modal viewer - PDF opens in new tab via native viewer - Cleanup wired into deleteErstgespraech and deleteAllData Deployed and ready for testing.' | tea api -X POST repos/felixfoertsch/tpf/issues/2/comments -F "body=@-" ```