# 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 (1–5) 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/. backups/.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 `