Files
kochwas/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md

245 lines
7.2 KiB
Markdown
Raw Normal View History

# 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/014_recipe_views.sql`
(Numbering: aktuell ist die letzte Migration `013_shopping_list.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.