diff --git a/docs/superpowers/plans/2026-03-12-document-scanning.md b/docs/superpowers/plans/2026-03-12-document-scanning.md new file mode 100644 index 0000000..2af995f --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-document-scanning.md @@ -0,0 +1,666 @@ +# 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=@-" +```