docs(spec): Hauptseite Zuletzt-angesehen-Sort + Collapsibles
Spec fuer zwei Hauptseite-Features aus Brainstorming am 2026-04-22: 1) Neue Sort-Option "Zuletzt angesehen" fuer "Alle Rezepte". Tracking per Profil in neuer SQLite-Tabelle recipe_views, beim Laden der Detail-Seite per Beacon (POST /api/recipes/[id]/view) gesetzt. Server-Sort macht LEFT JOIN mit ORDER BY last_viewed_at DESC NULLS LAST, alphabetischer Tiebreaker. 2) "Deine Favoriten" und "Zuletzt hinzugefuegt" auf-/zuklappbar. Default offen, User-Wahl persistiert in localStorage pro Device. Header als button mit Chevron + Count-Pill, slide-Transition. "Alle Rezepte" bleibt nicht-collapsibel (Hauptliste). Spec deckt Schema, API-Endpoint, DB-Layer, Markup-Pattern, Reactive-Refetch bei Profile-Switch, Snapshot-Kompatibilitaet (rehydrate muss profile_id mitbekommen), Test-Strategie und Reihenfolge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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:
|
||||
|
||||
```svelte
|
||||
<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 auf
|
||||
`transform: 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`'s `slide`, ~180 ms
|
||||
|
||||
### Persistenz-Format
|
||||
|
||||
```json
|
||||
{ "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`): nach
|
||||
`applyMigrations` muss `recipe_views` existieren mit erwarteten
|
||||
Spalten
|
||||
|
||||
### View-Endpoint
|
||||
|
||||
- `POST /api/recipes/[id]/view` Integration-Test:
|
||||
- Erstes POST → Row mit `last_viewed_at` ungefähr `now`
|
||||
- Zweites POST → gleiche Row, `last_viewed_at` aktualisiert
|
||||
- POST mit ungültiger profile_id → 404
|
||||
- POST mit ungültiger recipe_id → 404
|
||||
- POST ohne profile_id im Body → 400
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
1. Migration + DB-Layer + Sort-Query (`search-local.ts`-Erweiterung)
|
||||
2. View-Endpoint (`POST /api/recipes/[id]/view`) + Client-Beacon in
|
||||
`recipes/[id]/+page.svelte`
|
||||
3. Sort-Option in `+page.svelte` UI + API-Param weiterreichen +
|
||||
profile_id in `loadAllMore`/`rehydrateAll`/`setAllSort` durchreichen
|
||||
4. Collapsible-Pattern in `+page.svelte` für Favoriten und Recent
|
||||
|
||||
Jede Phase atomar committen + pushen.
|
||||
Reference in New Issue
Block a user