diff --git a/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md b/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md new file mode 100644 index 0000000..379c9ff --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md @@ -0,0 +1,243 @@ +# Hauptseite: "Zuletzt angesehen" Sort + Collapsible Sections + +## Kontext + +Die Hauptseite (`src/routes/+page.svelte`) hat heute drei Sektionen — "Deine +Favoriten", "Zuletzt hinzugefügt", "Alle Rezepte" — und vier Sort-Optionen +für "Alle Rezepte" (Name, Bewertung, Zuletzt gekocht, Hinzugefügt). Der +User möchte: + +1. Eine fünfte Sort-Option "Zuletzt angesehen" für "Alle Rezepte" +2. "Deine Favoriten" und "Zuletzt hinzugefügt" auf-/zuklappbar machen + +Beides reduziert visuelle Last und gibt Zugriff auf "kürzlich +beschäftigte mich" Rezepte ohne Suche. + +## Design-Entscheidungen (durch Brainstorming bestätigt) + +- **View-Tracking**: zählt sofort beim Laden der Detailseite — kein Threshold +- **Storage**: SQLite, pro Profil (konsistent mit Ratings, Cooked, Wishlist) +- **Collapsibles**: standardmäßig offen, User-Wahl persistiert pro Device + +## Sektion 1 — Schema & View-Tracking + +### Migration + +Neue Datei `src/lib/server/db/migrations/010_recipe_views.sql`: + +```sql +CREATE TABLE recipe_views ( + profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + last_viewed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (profile_id, recipe_id) +); +CREATE INDEX idx_recipe_views_recent + ON recipe_views(profile_id, last_viewed_at DESC); +``` + +Idempotent über `INSERT OR REPLACE` — mehrfache Visits ein- und desselben +Profils auf dasselbe Rezept führen nur zur Aktualisierung des Timestamps, +kein Multi-Insert. + +Cascade auf beide FKs: löscht ein User ein Rezept oder ein Profil, gehen +zugehörige Views automatisch mit. + +### API + +Neuer Endpoint `POST /api/recipes/[id]/view`: + +``` +Request body: { "profile_id": number } +Response: 204 No Content +Errors: + - 400 wenn profile_id fehlt oder kein Number + - 404 wenn Recipe nicht existiert (FK-Violation) + - 404 wenn Profil nicht existiert (FK-Violation) +``` + +Implementation: einfache `INSERT OR REPLACE` mit den IDs. `last_viewed_at` +nutzt den Default (`datetime('now')`). + +### Client-Hook + +In `src/routes/recipes/[id]/+page.svelte`, in `onMount`: + +```ts +if (profileStore.active) { + void fetch(`/api/recipes/${recipe.id}/view`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ profile_id: profileStore.active.id }) + }); +} +``` + +Fire-and-forget, kein UI-Block, kein Error-Handling — wenn der Beacon +fehlschlägt, ist es kein User-Visible-Bug, das nächste View korrigiert +es. + +## Sektion 2 — Sort "Zuletzt angesehen" + +### Page + +In `src/routes/+page.svelte`: + +```ts +type AllSort = 'name' | 'rating' | 'cooked' | 'created' | 'viewed'; +const ALL_SORTS = [ + ..., + { value: 'viewed', label: 'Zuletzt angesehen' } +]; +``` + +### API + +`GET /api/recipes/all` bekommt einen optionalen `profile_id`-Query-Param. +Der Endpoint reicht ihn an `listAllRecipesPaginated` durch. + +### DB-Layer + +`listAllRecipesPaginated` in `src/lib/server/recipes/search-local.ts` +bekommt einen optionalen `profileId: number | null`-Parameter. Wenn +`sort === 'viewed'` UND `profileId !== null`: + +```sql +SELECT r.*, ... +FROM recipes r +LEFT JOIN recipe_views v + ON v.recipe_id = r.id AND v.profile_id = :profileId +ORDER BY v.last_viewed_at DESC NULLS LAST, + r.title COLLATE NOCASE ASC +LIMIT :limit OFFSET :offset +``` + +Bei `sort === 'viewed'` ohne `profileId`: fällt auf alphabetische +Sortierung zurück (kein Crash, sinnvolles Default-Verhalten). + +### Reactive Refetch bei Profile-Switch + +Auf Home-Page-Ebene: ein `$effect` der auf `profileStore.activeId` lauscht +und — wenn `allSort === 'viewed'` — `setAllSort('viewed')` retriggert +(forciert Refetch mit neuem profile_id). Sonst (anderer Sort) keine +Aktion, weil andere Sorts nicht profilabhängig sind. + +### Snapshot-Kompatibilität + +Der existierende `rehydrateAll(sort, count, exhausted)` in `+page.svelte` +muss `profile_id` mitschicken, sonst zeigt der Back-Nav für sort='viewed' +einen anderen Inhalt als vor dem Forward-Klick. Das gleiche gilt für +`loadAllMore` und `setAllSort`. + +## Sektion 3 — Auf-/Zuklappbare Sektionen + +### State + +In `src/routes/+page.svelte`: + +```ts +type CollapseKey = 'favorites' | 'recent'; +let collapsed = $state>({ + favorites: false, + recent: false +}); + +const STORAGE_KEY = 'kochwas.collapsed.sections'; + +function toggle(key: CollapseKey) { + collapsed[key] = !collapsed[key]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed)); +} +``` + +In `onMount`: aus localStorage parsen, fehlerhafte JSON ignorieren +(default-state behalten). + +### Markup + +Pro Sektion: + +```svelte +
+ + {#if !collapsed.favorites} +
+
+
+ {/if} +
+``` + +### Visual / CSS + +- Header `