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>
2026-04-22 13:54:52 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-04-22 14:00:22 +02:00
|
|
|
Neue Datei `src/lib/server/db/migrations/014_recipe_views.sql`
|
|
|
|
|
(Numbering: aktuell ist die letzte Migration `013_shopping_list.sql`):
|
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>
2026-04-22 13:54:52 +02:00
|
|
|
|
|
|
|
|
```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.
|