Tabellen-Konvention im Repo ist singular — siehe Code-Review-Findings
zu Task 1 (commit 543008b). Plan und Spec angeglichen damit weitere
Tasks nicht mit dem alten Plural arbeiten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.2 KiB
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:
- Eine fünfte Sort-Option "Zuletzt angesehen" für "Alle Rezepte"
- "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/014_recipe_view.sql
(Numbering: aktuell ist die letzte Migration 013_shopping_list.sql):
CREATE TABLE recipe_view (
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_view_recent
ON recipe_view(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:
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:
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:
SELECT r.*, ...
FROM recipes r
LEFT JOIN recipe_view 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:
type CollapseKey = 'favorites' | 'recent';
let collapsed = $state<Record<CollapseKey, boolean>>({
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:
<section class="listing">
<button
class="section-head"
onclick={() => toggle('favorites')}
aria-expanded={!collapsed.favorites}
>
<ChevronDown size={18} class:rotated={collapsed.favorites} />
<h2>Deine Favoriten</h2>
<span class="count">{favorites.length}</span>
</button>
{#if !collapsed.favorites}
<div transition:slide={{ duration: 180 }}>
<ul class="cards">…</ul>
</div>
{/if}
</section>
Visual / CSS
- Header
<button>: transparenter Border, full-width,display: flex,align-items: center,gap: 0.5rem,min-height: 44px(Tap-Target) - Chevron-Icon (lucide-svelte
ChevronDown): rotiert auftransform: rotate(-90deg)wenn.rotated - Count-Pill rechts: kleiner grauer Text, hilft zu sehen wie viel hinter einer zugeklappten Sektion steckt
- Hover: leichter Hintergrund (
#f4f8f5, wie andere interaktive Elemente) - Animation:
svelte/transition'sslide, ~180 ms
Persistenz-Format
{ "favorites": false, "recent": true }
Truthy = collapsed. Default-Zustand wenn key fehlt: beide false.
"Alle Rezepte" bleibt nicht-collapsible
Hauptliste, immer sichtbar — User würde das Scrollen verlieren.
Test-Strategie
Schema/Migration
- Migrations-Test (existierendes Pattern in
tests/integration): nachapplyMigrationsmussrecipe_viewexistieren mit erwarteten Spalten
View-Endpoint
POST /api/recipes/[id]/viewIntegration-Test:- Erstes POST → Row mit
last_viewed_atungefährnow - Zweites POST → gleiche Row,
last_viewed_ataktualisiert - POST mit ungültiger profile_id → 404
- POST mit ungültiger recipe_id → 404
- POST ohne profile_id im Body → 400
- Erstes POST → Row mit
Sort-Logik
- Unit-Test für
listAllRecipesPaginated(db, 'viewed', limit, offset, profileId):- Mit Views-Daten: angesehene Rezepte zuerst (DESC nach
last_viewed_at), Rest alphabetisch - Ohne profileId: fallback auf alphabetisch
- Mit profileId aber ohne Views: alle als NULL → alphabetisch
- Mit Views-Daten: angesehene Rezepte zuerst (DESC nach
Collapsibles (manuell oder unit)
- localStorage-Persistenz: Toggle, Reload, gleicher State
- Default-State wenn localStorage leer/corrupt: beide offen
- Ein Unit-Test für eine reine Helper-Funktion (parse/serialize), Markup ist Snapshot-mässig nicht so wertvoll testbar
Reihenfolge der Umsetzung
- Migration + DB-Layer + Sort-Query (
search-local.ts-Erweiterung) - View-Endpoint (
POST /api/recipes/[id]/view) + Client-Beacon inrecipes/[id]/+page.svelte - Sort-Option in
+page.svelteUI + API-Param weiterreichen + profile_id inloadAllMore/rehydrateAll/setAllSortdurchreichen - Collapsible-Pattern in
+page.sveltefür Favoriten und Recent
Jede Phase atomar committen + pushen.