chore: initialize repo with PRD

Adds .gitattributes for LF line endings and initial product/design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 14:56:20 +02:00
commit 59dc3623fe
2 changed files with 440 additions and 0 deletions

View File

@@ -0,0 +1,439 @@
# Kochwas — Produktanforderungen und Designspezifikation
**Datum:** 2026-04-17
**Autor:** Hendrik (mit Claude)
**Status:** Draft v1 — zur Review
---
## 1. Überblick
**Kochwas** ist eine selbstgehostete Progressive Web App (PWA) für Rezepte. Sie ermöglicht der Familie, Rezepte aus einer kuratierten Liste deutscher Koch-Websites automatisch zu importieren, lokal in einem einheitlichen Format zu speichern und mobil (Tablet/Smartphone) in der Küche zu nutzen.
Die App läuft ausschließlich im Homelab des Anwenders. Mobile Nutzung ist der primäre Anwendungsfall.
## 2. Problem & Motivation
Koch-Websites im Internet haben jeweils eigenes Layout, Werbung, Cookie-Banner und wechselnde URLs. Beim Kochen am Tablet oder Smartphone ist das lästig und unzuverlässig (Seiten verschwinden, Layout ändert sich, Bildschirm geht aus). Eine eigene Sammlung mit einheitlicher Darstellung, offline-verfügbar, mit Familienbewertungen und Notizen, löst das.
## 3. Ziele & Nicht-Ziele
### 3.1 Ziele (MVP)
- Rezepte aus einer festgelegten Liste von Domains per Klick lokal speichern (strukturiert, nicht als Screenshot oder HTML-Dump).
- Rezepte in einheitlichem Layout darstellen (Header → Zutatenliste → Zubereitung).
- Familienmitglieder können bewerten, kommentieren, umbenennen, löschen, als Favorit markieren.
- Kochjournal: Wer hat wann was gekocht.
- Mobile-first: großer Touch-Targets, Wake-Lock, Portrait-optimiertes Layout.
- Suche: lokale Volltextsuche zuerst, dann optional Internetsuche innerhalb der Whitelist.
- Selbst gehostet per Docker Compose; Backup per Volume-Copy oder integriertem ZIP-Export.
### 3.2 Nicht-Ziele (MVP)
- Öffentliches Hosting / Mehrmandanten-Betrieb.
- Einkaufslistenfunktion.
- Passwort-Login oder echte Authentifizierung.
- KI-gestützte Rezeptextraktion (geplant als Phase 2, siehe § 13).
- Import bestehender Rezept-Bibliotheken (Paprika, Markdown, etc.).
- Native iOS/Android-Apps (PWA ist ausreichend).
- Übersetzung fremdsprachiger Rezepte.
## 4. Nutzer & Profile
**Hauptnutzer:** Familie des Anwenders (mehrere Personen, gemeinsames Gerät oder persönliche Geräte, gemeinsame Homelab-Instanz).
**Profilmodell:** „Profilauswahl ohne Login" — beim App-Start wählt man aus einer Liste der angelegten Profile. Die Auswahl wird per LocalStorage gemerkt und kann jederzeit oben rechts gewechselt werden. Kein Passwort, keine Sitzungsverwaltung.
**Rechte:** Alle Profile haben dieselben Rechte — inklusive Verwalten der Whitelist, Löschen von Rezepten, Anlegen neuer Profile. Kein Admin-Flag.
**Pro-Profil-Daten:** Sternbewertung je Rezept, Favoritenliste, Einträge im Kochjournal.
**Geteilte Daten:** Rezepte selbst, Kommentare (mit Autor-Zuordnung), Tags, Whitelist.
## 5. Funktionale Anforderungen
### 5.1 Startseite („Google-like")
- Großes, zentriertes Suchfeld.
- Profil-Switcher oben rechts.
- Optional darunter: zuletzt gekochte Rezepte des aktiven Profils als Karten.
### 5.2 Suche — lokal
- Volltextsuche über Titel, Beschreibung, Zutaten, Tags (SQLite FTS5).
- Ranking: Titel > Tag > Zutat.
- Ergebniskarten: Bild, Titel, Domain, Durchschnittsbewertung, Datum letztes Kochen.
- Wenn keine Treffer oder Nutzer wünscht Internetsuche: Button „Im Internet suchen".
### 5.3 Suche — Internet
- Ausgeführt gegen die selbst gehostete SearXNG-Instanz, eingeschränkt per `site:`-Filter auf die aktive Whitelist.
- Zweite Absicherung: Server filtert die Trefferliste nochmals gegen die Whitelist (schützt vor SearXNG-Fehlkonfigurationen).
- Ergebniskarten: Titel, Domain, Snippet, optional Thumbnail.
### 5.4 Vorschau & Import
- Klick auf Web-Treffer öffnet **Vorschauseite**:
- Server lädt die Quell-URL, extrahiert JSON-LD, rendert das Rezept im einheitlichen Layout, **ohne zu speichern**.
- Oben Banner „Vorschau — noch nicht gespeichert".
- Buttons: **In meine Sammlung speichern** / **Zurück**.
- Beim Speichern wird das Rezept frisch geladen (kein stale Cache), Bild lokal abgelegt, in SQLite eingetragen, auf die Rezeptansicht weitergeleitet.
- Duplikatserkennung: dieselbe `source_url` wird nicht doppelt importiert; stattdessen zum bestehenden Eintrag weiterleiten.
### 5.5 Rezeptansicht (einheitliches Layout)
Immer in derselben Reihenfolge, egal woher das Rezept stammt:
1. **Header:** Titel, Bild, Metadaten (Portionen, Prep/Cook/Total, Küche, Kategorie, Quelle als Link).
2. **Zutatenliste** mit Portionen-Slider (+/- Buttons und Schieberegler), skaliert Mengen automatisch; nicht-parsebare Zutaten werden als Freitext gezeigt (keine Skalierung für diese Zeilen).
3. **Zubereitung** als nummerierte Schritte.
4. **Bewertungen & Kommentare** am Ende.
Aktionen in der Ansicht:
- Sternbewertung (15) des aktiven Profils.
- Herz-Icon: Favorit des aktiven Profils.
- Button „Heute gekocht" → fügt Eintrag ins Kochjournal mit `now()` und aktivem Profil.
- Kommentar hinzufügen (geteilt, mit Autor).
- Umbenennen, Löschen (mit Bestätigung).
- Drucken (öffnet `/recipes/:id/print`).
- **Wake-Lock:** beim Aufruf auf mobilen Geräten automatisch angefordert (Bildschirm bleibt an), freigegeben beim Verlassen der Seite.
### 5.6 Druckansicht
- Eigene Route `/recipes/:id/print` mit druckoptimiertem Layout (keine Navigation, große Schrift, einspaltig, eingebettetes Bild, Quell-URL als Fußzeile).
### 5.7 Tagging
- Freitext-Tags, geteilt, per Rezept mehrere.
- Auto-Vorschläge bereits vergebener Tags.
- Tags aus JSON-LD (`recipeCategory`, `recipeCuisine`, `keywords`) werden beim Import automatisch übernommen.
### 5.8 Verwaltung / Admin-UI
- `/admin/domains` — Whitelist verwalten (hinzufügen/entfernen, Anzeigename setzen).
- `/admin/profiles` — Profile anlegen, umbenennen, löschen.
- `/admin/backup` — ZIP-Download (DB + images), ZIP-Restore (mit Bestätigung).
## 6. Nicht-funktionale Anforderungen
| Kategorie | Anforderung |
|---|---|
| Performance | Suche auf Mobil < 300 ms bei 1.000 Rezepten; Rezeptansicht-LCP < 2 s |
| Verfügbarkeit | Lokal: quasi 100 % (Docker restart policy); offline: gespeicherte Rezepte dank Service Worker abrufbar |
| Datenvolumen | Zielgröße: mehrere Tausend Rezepte + Bilder in wenigen GB |
| Browser-Support | Aktuelle Versionen von Safari (iOS/iPadOS), Chrome (Android), Firefox; Wake-Lock fällt auf Nicht-Unterstützung still durch |
| Sicherheit | Ausschließlich im Heimnetz; Import-Fetch mit Timeout 10 s, max. 10 MB, nur http(s), Ziel-URL muss Whitelist-Domain sein |
| Sprache | UI deutsch; Rezepte in Originalsprache (keine Übersetzung) |
| Zugänglichkeit | Touch-Targets ≥ 44 × 44 px, Kontrast AA, Screen-Reader-Basics |
## 7. Architektur
### 7.1 Container-Diagramm
```
┌──────────────────────────────────────────────────────────┐
│ Browser / PWA (SvelteKit Client) │
│ Startseite │ Suche │ Preview │ RecipeView │ Admin │
│ Wake-Lock, Service Worker (offline für gespeicherte) │
└─────────────────────┬────────────────────────────────────┘
│ HTTP(S) im LAN
┌─────────────────────▼────────────────────────────────────┐
│ SvelteKit Server (Node) │
│ Routes/APIs, Importer, Scaler, JSON-LD-Extractor, │
│ Ingredient-Parser, Image-Downloader │
└──────┬──────────────────────────────────┬────────────────┘
│ │
┌──────▼────────────┐ ┌────────▼─────────────┐
│ SQLite (WAL) │ │ SearXNG (Container) │
│ /data/kochwas.db │ │ /search?format=json │
│ /data/images/* │ │ Whitelist als site: │
└───────────────────┘ └──────────────────────┘
```
### 7.2 Tech-Stack
- **Frontend/Backend:** SvelteKit + TypeScript (Node-Adapter)
- **Datenbank:** SQLite via `better-sqlite3` (synchron, schnell, eine Datei)
- **Scraping-Stack:** `undici` (fetch), `linkedom` oder `node-html-parser` zum JSON-LD-Herausziehen
- **Bilder:** direkter Download, optional `sharp` für Resize (max. 1200 px Längsseite)
- **Tests:** Vitest (Unit + Integration), Playwright (E2E, mit Mobile-Emulation)
- **Paketierung:** Docker (multi-stage Build), Docker Compose
- **CI:** Gitea Actions im Homelab
### 7.3 Modulgrenzen
Server-seitig in `src/lib/server/`:
| Modul | Aufgabe | Schnittstelle |
|---|---|---|
| `db` | SQLite-Verbindung, Migrationen, typed Queries | Funktionen pro Tabelle |
| `recipe-importer` | URL → strukturiertes Rezept (mit/ohne Save) | `preview(url)`, `import(url)` |
| `json-ld-extractor` | HTML → schema.org/Recipe-JSON | `extract(html)` |
| `ingredient-parser` | Freitext → `{qty, unit, name, note}` | `parse(text)` |
| `image-downloader` | URL → lokale Datei (dedupliziert per SHA256) | `download(url)` |
| `search-local` | Query → Rezepte via FTS5 | `search(q, limit)` |
| `search-web` | Query → Whitelist-gefilterte Treffer | `search(q)` |
| `scaler` | Rezept × Faktor → skalierte Zutaten | `scale(recipe, factor)` |
Jedes Modul hat einen eigenen Unit-Test mit Fixtures.
Client-seitig: eine einzige `RecipeView`-Komponente rendert sowohl die Vorschau als auch die gespeicherte Ansicht — so ist die einheitliche Darstellung garantiert.
### 7.4 Routen
| Route | Methode | Zweck |
|---|---|---|
| `/` | GET | Startseite |
| `/search` | GET | Lokale Trefferliste |
| `/search/web` | GET | Web-Trefferliste |
| `/api/recipes/search` | GET `?q=` | Lokale Suche JSON |
| `/api/recipes/search/web` | GET `?q=` | Web-Suche JSON |
| `/api/recipes/preview` | GET `?url=` | Preview-Payload (nicht persistiert) |
| `/api/recipes/import` | POST `{url}` | Persistenter Import |
| `/recipes/[id]` | GET | Rezeptansicht |
| `/recipes/[id]/print` | GET | Druckansicht |
| `/api/recipes/[id]` | PATCH/DELETE | Umbenennen/Löschen |
| `/api/recipes/[id]/rating` | PUT `{profileId, stars}` | Bewerten |
| `/api/recipes/[id]/favorite` | PUT `{profileId}` / DELETE | Favorit |
| `/api/recipes/[id]/cooked` | POST `{profileId}` | Kochjournal-Eintrag |
| `/api/recipes/[id]/comments` | POST `{profileId, text}` / DELETE | Kommentare |
| `/admin/domains` | GET / POST / DELETE | Whitelist |
| `/admin/profiles` | GET / POST / PATCH / DELETE | Profile |
| `/admin/backup` | GET / POST | Backup-Export/Restore |
| `/api/health` | GET | `{ db, searxng }` |
## 8. Datenmodell (SQLite)
```sql
profile(
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
avatar_emoji TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
recipe(
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
source_url TEXT UNIQUE,
source_domain TEXT,
image_path TEXT,
servings_default INTEGER,
servings_unit TEXT,
prep_time_min INTEGER,
cook_time_min INTEGER,
total_time_min INTEGER,
cuisine TEXT,
category TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ingredient(
id INTEGER PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
quantity REAL,
unit TEXT,
name TEXT NOT NULL,
note TEXT,
raw_text TEXT
);
step(
id INTEGER PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
text TEXT NOT NULL
);
tag(
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
recipe_tag(
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
PRIMARY KEY (recipe_id, tag_id)
);
rating(
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
stars INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5),
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (recipe_id, profile_id)
);
comment(
id INTEGER PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
favorite(
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (recipe_id, profile_id)
);
cooking_log(
id INTEGER PRIMARY KEY,
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
cooked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
allowed_domain(
id INTEGER PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
display_name TEXT,
added_by_profile_id INTEGER REFERENCES profile(id),
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Volltext-Index
CREATE VIRTUAL TABLE recipe_fts USING fts5(
title, description, ingredients_concat, tags_concat,
content='', tokenize='unicode61 remove_diacritics 2'
);
-- Trigger auf recipe/ingredient/tag synchronisieren recipe_fts
```
## 9. Schlüssel-Abläufe (Zusammenfassung)
### 9.1 Lokale Suche (Golden Path)
1. Eingabe im Suchfeld (debounced, ab 3 Zeichen).
2. `GET /api/recipes/search?q=…` → FTS5-Abfrage mit gewichteten Feldern.
3. Trefferliste als Karten, unten Button „Im Internet suchen".
### 9.2 Internetsuche & Vorschau & Import
1. `GET /api/recipes/search/web?q=…` → Whitelist laden → SearXNG-Query mit `site:`-Filtern → Doppel-Whitelist-Check → Liste.
2. Klick auf Treffer → `GET /api/recipes/preview?url=…` → Server holt HTML, extrahiert JSON-LD, gibt Rezept-JSON zurück (nichts in DB).
3. Client rendert Preview in `RecipeView` mit Banner „nicht gespeichert".
4. Klick „Speichern" → `POST /api/recipes/import {url}` → frisches Laden, Bild-Download, DB-Insert, Redirect zur Rezept-ID.
### 9.3 Portionen-Skalierung
- Slider ändert Faktor `f = newServings / servings_default`.
- Jede parsebare Zutat wird als `qty × f` dargestellt, sinnvoll gerundet.
- Nicht-parsebare Zutaten (nur `raw_text`) bleiben unverändert.
- Rein Client-side (keine DB-Änderung); außer beim Druck, wo der Server direkt skaliert rendert.
### 9.4 Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| Quell-URL 404 / Timeout | Klare Fehlermeldung, kein Teileintrag |
| Kein JSON-LD vorhanden | Hinweis, dass automatische Erkennung nicht möglich (Phase 2: LLM-Fallback) |
| JSON-LD unvollständig | Best-effort speichern, Platzhalter wo nötig |
| Zutat nicht parsebar | `raw_text` füllen, aus Skalierung ausnehmen |
| SearXNG nicht erreichbar | Meldung „Suche zurzeit nicht verfügbar", lokale Ergebnisse bleiben |
| Bildquelle unerreichbar | `image_path = NULL`, Platzhalterbild |
| Offline | PWA zeigt gespeicherte Rezepte, Import/Web-Suche deaktiviert |
## 10. UX-Prinzipien (mobile-first)
- Portrait-optimiert; Tablets bekommen mehr Weißraum, nicht fundamental anderes Layout.
- Touch-Targets min. 44 × 44 px; Abstände groß.
- Rezeptansicht: Tabs „Zutaten" / „Zubereitung" statt langem Scrollen.
- Portionen-Slider mit +/- Buttons zusätzlich zum Ziehen.
- Wake-Lock automatisch auf der Rezeptseite, Indikator im UI.
- Bilder responsive, max. 1200 px Längsseite, mit `loading=lazy`.
- Startseite: großes Suchfeld; Autofokus nur auf Desktop (nicht auf iOS/Safari, um Keyboard-Pop-up zu vermeiden).
## 11. Tests
| Ebene | Werkzeug | Anteil | Inhalt |
|---|---|---|---|
| Unit | Vitest | ~60 % | `ingredient-parser`, `scaler`, `json-ld-extractor`, ISO8601-Duration |
| Integration | Vitest + SQLite-in-memory | ~25 % | Importer mit HTML-Fixtures, FTS5-Suche, CRUD + Kaskadierung, Whitelist-Filter |
| E2E | Playwright (Mobile-Emulation) | ~15 % | Golden Path, Skalierung, Kochjournal, Import-Fehlerfall |
HTML-Fixtures der drei initialen Domains (Chefkoch, Emmi kocht einfach, Experimente aus meiner Küche) liegen unter `tests/fixtures/`.
## 12. Deployment & Betrieb
### 12.1 Entwicklung (lokal)
- Docker Desktop mit Compose für `searxng`-Container.
- `npm run dev` startet SvelteKit auf `localhost:5173`, liest gegen `./dev-data`-Verzeichnis.
- Gitea Actions für CI (Tests + Docker-Image-Build).
### 12.2 Produktion (Homelab)
`docker-compose.yml`:
```yaml
services:
kochwas:
image: kochwas:latest
ports: ["3000:3000"]
volumes:
- ./data:/data
environment:
- DATABASE_PATH=/data/kochwas.db
- IMAGE_DIR=/data/images
- SEARXNG_URL=http://searxng:8080
- NODE_ENV=production
depends_on: [searxng]
restart: unless-stopped
searxng:
image: searxng/searxng:latest
volumes: ["./searxng:/etc/searxng"]
environment:
- BASE_URL=http://searxng:8080/
- INSTANCE_NAME=kochwas-search
restart: unless-stopped
```
Volume-Layout:
```
/data/
kochwas.db
kochwas.db-wal
images/<sha256>.<ext>
backups/<timestamp>.zip (optional)
```
### 12.3 Updates & Backups
- Update: Image neu bauen/pullen, `docker compose up -d`. Schema-Migrationen laufen beim Startup.
- Backup: Volume-Copy (Homelab-Backup) oder ZIP-Export via `/admin/backup`.
- Restore: ZIP-Upload (mit Bestätigungsdialog) oder Volume-Restore.
### 12.4 Health & Logs
- `/api/health` gibt `{ db: "ok", searxng: "ok" | "degraded" }` zurück.
- Access- und Fehler-Logs auf STDOUT (`docker logs`).
- Kein Metrics-Stack im MVP.
### 12.5 Sicherheit
- Ausschließlicher Betrieb im Heimnetz; Remote-Zugriff (falls gewünscht) über VPN, außerhalb des App-Scopes.
- Keine Authentifizierung innerhalb der App (bewusst: niedrigschwellig für Familie).
- Import-Fetch: Timeout 10 s, max. 10 MB Body, nur `http(s)://`, Whitelist-Check vor Import, User-Agent gesetzt.
### 12.6 Line-Endings / Repo-Hygiene
- `.gitattributes` mit `* text=auto eol=lf` (Windows-Dev-Umgebung, LF im Repo).
## 13. MVP-Scope vs. spätere Phasen
**MVP (Phase 1) beinhaltet:**
- Profilauswahl, Whitelist-Admin, Profil-Admin
- Lokale Suche (FTS5), Internetsuche via SearXNG
- Import via JSON-LD (Chefkoch, Emmi kocht einfach, Experimente aus meiner Küche als initiale Whitelist)
- Vorschau-Seite
- Einheitliche Rezeptansicht, Portionen-Skalierung, Druckansicht
- Bewertung, Favoriten, Kommentare, Kochjournal, Umbenennen, Löschen
- Tags (auto + manuell), Volltextsuche über Zutaten
- Bilder lokal speichern
- Wake-Lock
- Backup / Restore ZIP
- PWA-Offline-Zugriff auf gespeicherte Rezepte
- Docker-Compose-Deployment + CI
**Phase 2 (explizit geplant, nicht MVP):**
- LLM-Fallback-Extractor für Seiten ohne JSON-LD.
**Phase 3+ (Ideen, nicht verplant):**
- Einkaufsliste, Import aus anderen Rezept-Apps, Kochjournal-Auswertungen (Top 10, Saison-Empfehlungen), Rollen/Admin-Flags, mehrere Whitelists pro Profil.
## 14. Offene Punkte
Keine — alle Designentscheidungen sind im Brainstorming geklärt.
## 15. Glossar
- **Whitelist / Allowed Domain:** Liste der Domains, die als Rezeptquelle erlaubt sind.
- **JSON-LD:** strukturierte Metadaten (schema.org) im `<script type="application/ld+json">`-Tag einer Webseite.
- **FTS5:** SQLite-Volltextsuch-Erweiterung.
- **PWA:** Progressive Web App, installierbar aus dem Browser, offline-fähig.
- **Wake-Lock:** Browser-API, die den Bildschirm während der Nutzung wachhält.
- **SearXNG:** selbstgehostete Meta-Suchmaschine, Fork von Searx.
- **Profil:** Anzeigename + Emoji eines Familienmitglieds; kein Passwort.