Files
kochwas/docs/superpowers/specs/2026-04-22-views-and-collapsibles-design.md
hsiegeln 98894bb895 docs(plan): Implementation-Plan fuer Views-Sort + Collapsibles
10 Tasks (Migration -> Repo -> Sort-Branch -> API -> Beacon -> URL-
Helper -> Sort-Chip + Reactive Refetch -> Collapsibles -> Push&Verify)
mit TDD-Schritten, exakten Filepfaden und vollstaendigem Code in
jedem Step.

Spec-Migrationsnummer auf 014 korrigiert (war 010 — letzte aktuelle
Migration ist 013_shopping_list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:00:22 +02:00

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:

  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):

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:

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_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:

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 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

{ "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.