Adds .gitattributes for LF line endings and initial product/design spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
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_urlwird nicht doppelt importiert; stattdessen zum bestehenden Eintrag weiterleiten.
5.5 Rezeptansicht (einheitliches Layout)
Immer in derselben Reihenfolge, egal woher das Rezept stammt:
- Header: Titel, Bild, Metadaten (Portionen, Prep/Cook/Total, Küche, Kategorie, Quelle als Link).
- Zutatenliste mit Portionen-Slider (+/- Buttons und Schieberegler), skaliert Mengen automatisch; nicht-parsebare Zutaten werden als Freitext gezeigt (keine Skalierung für diese Zeilen).
- Zubereitung als nummerierte Schritte.
- 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/printmit 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),linkedomodernode-html-parserzum JSON-LD-Herausziehen - Bilder: direkter Download, optional
sharpfü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)
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)
- Eingabe im Suchfeld (debounced, ab 3 Zeichen).
GET /api/recipes/search?q=…→ FTS5-Abfrage mit gewichteten Feldern.- Trefferliste als Karten, unten Button „Im Internet suchen".
9.2 Internetsuche & Vorschau & Import
GET /api/recipes/search/web?q=…→ Whitelist laden → SearXNG-Query mitsite:-Filtern → Doppel-Whitelist-Check → Liste.- Klick auf Treffer →
GET /api/recipes/preview?url=…→ Server holt HTML, extrahiert JSON-LD, gibt Rezept-JSON zurück (nichts in DB). - Client rendert Preview in
RecipeViewmit Banner „nicht gespeichert". - 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 × fdargestellt, 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 devstartet SvelteKit auflocalhost:5173, liest gegen./dev-data-Verzeichnis.- Gitea Actions für CI (Tests + Docker-Image-Build).
12.2 Produktion (Homelab)
docker-compose.yml:
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/healthgibt{ 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
.gitattributesmit* 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.