Files
tpf/docs/superpowers/plans/2026-03-12-document-scanning.md
2026-03-12 22:20:10 +01:00

17 KiB

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), <input type="file">, 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

bun add -d fake-indexeddb
  • Step 2: Commit
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

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
npx vitest run src/shared/hooks/dokument-store.test.ts

Expected: FAIL — module ./dokument-store not found.

  • Step 3: Commit
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

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<IDBDatabase> {
	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<number> {
	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<Dokument, "id"> = {
			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<Dokument[]> {
	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<void> {
	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<void> {
	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<void> {
	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
npx vitest run src/shared/hooks/dokument-store.test.ts

Expected: all 5 tests PASS.

  • Step 3: Commit
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

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<Dokument[]>([]);
	const [viewDokument, setViewDokument] = useState<Dokument | null>(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 (
		<div className="space-y-2">
			<p className="text-xs font-medium text-muted-foreground">Dokumente</p>

			{dokumente.length > 0 && (
				<div className="grid grid-cols-3 gap-2">
					{dokumente.map((dok) => (
						<DokumentThumbnail
							key={dok.id}
							dokument={dok}
							onView={() => handleView(dok)}
							onDelete={() => handleDelete(dok.id)}
						/>
					))}
				</div>
			)}

			<label className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-input px-3 py-1.5 text-sm shadow-xs transition-colors hover:bg-accent">
				<Plus className="size-4" />
				Dokument hinzufügen
				<input
					type="file"
					accept="image/*,application/pdf"
					multiple
					className="sr-only"
					onChange={(e) => handleFiles(e.target.files)}
				/>
			</label>

			{viewDokument && (
				<ImageModal
					dokument={viewDokument}
					onClose={() => setViewDokument(null)}
				/>
			)}
		</div>
	);
}

function DokumentThumbnail({
	dokument,
	onView,
	onDelete,
}: {
	dokument: Dokument;
	onView: () => void;
	onDelete: () => void;
}) {
	const [url, setUrl] = useState<string | null>(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 (
		<div className="group relative overflow-hidden rounded-md border">
			<button
				type="button"
				onClick={onView}
				className="flex aspect-square w-full items-center justify-center bg-muted"
			>
				{isImage && url ? (
					<img
						src={url}
						alt={dokument.name}
						className="size-full object-cover"
					/>
				) : (
					<div className="flex flex-col items-center gap-1 p-2">
						<FileText className="size-8 text-muted-foreground" />
						<span className="line-clamp-1 text-xs text-muted-foreground">
							{dokument.name}
						</span>
					</div>
				)}
			</button>
			<button
				type="button"
				onClick={onDelete}
				className="absolute right-1 top-1 rounded-full bg-background/80 p-1 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100"
			>
				<Trash2 className="size-3 text-destructive" />
			</button>
		</div>
	);
}

function ImageModal({
	dokument,
	onClose,
}: {
	dokument: Dokument;
	onClose: () => void;
}) {
	const [url, setUrl] = useState<string | null>(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 (
		<div
			role="dialog"
			aria-label={dokument.name}
			className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
			onClick={onClose}
		>
			<button
				type="button"
				onClick={onClose}
				className="absolute right-4 top-4 rounded-full bg-background/80 p-2"
			>
				<X className="size-5" />
			</button>
			{url && (
				<img
					src={url}
					alt={dokument.name}
					className="max-h-full max-w-full object-contain"
					onClick={(e) => e.stopPropagation()}
				/>
			)}
		</div>
	);
}
  • Step 2: Verify no type errors
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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):

import { DokumentListe } from "./dokument-liste";
  • Step 2: Add DokumentListe inside ErstgespraechCard

In the ErstgespraechCard function, inside the {expanded && ...} block (line 185), add <DokumentListe> between the <SitzungList> and the editing section. The block at lines 185-207 becomes:

				<div className="mt-3 space-y-3">
					<SitzungList sprechstundeId={erstgespraech.id} onUpdate={onUpdate} />
					<DokumentListe sprechstundeId={erstgespraech.id} />
					{editing ? (

This inserts a single line after line 186.

  • Step 3: Verify build
npx tsc --noEmit && npx vite build 2>&1 | tail -5

Expected: no errors, build succeeds.

  • Step 4: Commit
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:

import { deleteDokumenteForSprechstunde } from "@/shared/hooks/dokument-store";

Replace the deleteErstgespraech function (lines 208-210):

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:

import { deleteAllDokumente } from "@/shared/hooks/dokument-store";

Add await deleteAllDokumente(); as the first line inside deleteAllData():

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
npx tsc --noEmit && npx vitest run

Expected: no type errors, all tests pass.

  • Step 4: Commit
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
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
npx vite build 2>&1 | tail -5

Expected: build succeeds with PWA output.

  • Step 3: Full test suite
npx vitest run

Expected: all tests pass (existing + 5 new dokument-store tests).

  • Step 4: Deploy and post Gitea comment
bash scripts/deploy.sh serve

Post comment on issue #2:

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=@-"