From 60021b879fce69a952311fcba77ce1a4eda54671 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:16:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(wishlist):=20per-user=20W=C3=BCnsche=20+?= =?UTF-8?q?=20Header-Badge=20mit=20Gesamtzahl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-Änderung (Migration 005): - Tabelle wishlist umgestellt auf PK (recipe_id, profile_id) - wishlist_like-Tabelle zusammengelegt — Liken WAR schon "will ich auch", also werden alle bestehenden Likes Memberships auf der neuen Tabelle. - Alt-Einträge mit added_by_profile_id werden migriert, anonyme gehen verloren (war inkonsistent, jetzt erzwingen wir profile_id NOT NULL). Repository: - listWishlist aggregiert pro Rezept: wanted_by_count, wanted_by_names (kommagetrennt), on_my_wishlist für das aktive Profil - listWishlistProfileIds(recipeId) für den Recipe-Page-Loader - countWishlistRecipes für das Header-Badge (DISTINCT recipe_id) - addToWishlist/removeFromWishlist/isOnMyWishlist alle mit profile_id als Pflicht API: - POST /api/wishlist: profile_id jetzt Pflicht (nullable raus) - DELETE /api/wishlist/[recipe_id]?profile_id=X (nur eigenes Entry) - /api/wishlist/[recipe_id]/like komplett entfernt (Konzept obsolet) - Neu: GET /api/wishlist/count → { count: } UI: - Header-Heart bekommt rotes Badge mit Zahl der Wunschliste-Rezepte. wishlistStore in $lib/client/wishlist.svelte.ts hält den Count reaktiv; Refresh auf Mount, nach Add/Remove, beim Öffnen der Wunschliste. - Recipe-Detail: Loader liefert wishlist_profile_ids; onMyWishlist ist ein $derived. Toggle fragt aktives Profil (alertAction sonst), mutiert die lokale Liste + ruft wishlistStore.refresh. - Wunschliste-Seite: Heart toggelt eigenen Wunsch, Count zeigt Gesamt- wünsche, kommagetrennte Namen zeigen "wer will". Trash-Button entfernt — Heart-off reicht jetzt. Tests (99 → 99, 8 neu geschrieben): - Per-User-Add/Remove, aggregierte Counts, on_my_wishlist, Cascades bei Recipe/Profile-Delete, countWishlistRecipes = DISTINCT. --- src/lib/client/wishlist.svelte.ts | 16 +++ .../db/migrations/005_wishlist_per_user.sql | 29 ++++ src/lib/server/wishlist/repository.ts | 81 ++++++----- src/routes/+layout.svelte | 31 +++- src/routes/api/wishlist/+server.ts | 6 +- .../api/wishlist/[recipe_id]/+server.ts | 15 +- .../api/wishlist/[recipe_id]/like/+server.ts | 31 ---- src/routes/api/wishlist/count/+server.ts | 8 ++ src/routes/recipes/[id]/+page.server.ts | 12 +- src/routes/recipes/[id]/+page.svelte | 44 +++--- src/routes/wishlist/+page.svelte | 83 ++++++----- tests/integration/wishlist.test.ts | 133 +++++++++--------- 12 files changed, 282 insertions(+), 207 deletions(-) create mode 100644 src/lib/client/wishlist.svelte.ts create mode 100644 src/lib/server/db/migrations/005_wishlist_per_user.sql delete mode 100644 src/routes/api/wishlist/[recipe_id]/like/+server.ts create mode 100644 src/routes/api/wishlist/count/+server.ts diff --git a/src/lib/client/wishlist.svelte.ts b/src/lib/client/wishlist.svelte.ts new file mode 100644 index 0000000..c5ffff5 --- /dev/null +++ b/src/lib/client/wishlist.svelte.ts @@ -0,0 +1,16 @@ +class WishlistStore { + count = $state(0); + + async refresh(): Promise { + try { + const res = await fetch('/api/wishlist/count'); + if (!res.ok) return; + const body = await res.json(); + this.count = typeof body.count === 'number' ? body.count : 0; + } catch { + // keep last known count on network error + } + } +} + +export const wishlistStore = new WishlistStore(); diff --git a/src/lib/server/db/migrations/005_wishlist_per_user.sql b/src/lib/server/db/migrations/005_wishlist_per_user.sql new file mode 100644 index 0000000..c2e4f0f --- /dev/null +++ b/src/lib/server/db/migrations/005_wishlist_per_user.sql @@ -0,0 +1,29 @@ +-- Wishlist: from "one entry per recipe" to "per-user membership". +-- Multiple profiles can now wish for the same recipe. The old wishlist_like +-- table merges into this — liking WAS already "me too", so existing likes +-- become wishlist memberships. + +CREATE TABLE wishlist_new ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +-- Preserve existing explicit additions (only if a profile was attached) +INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at) + SELECT recipe_id, added_by_profile_id, added_at + FROM wishlist + WHERE added_by_profile_id IS NOT NULL; + +-- Likes become memberships +INSERT OR IGNORE INTO wishlist_new (recipe_id, profile_id, added_at) + SELECT recipe_id, profile_id, created_at + FROM wishlist_like; + +DROP TABLE wishlist_like; +DROP TABLE wishlist; +ALTER TABLE wishlist_new RENAME TO wishlist; + +CREATE INDEX idx_wishlist_profile ON wishlist(profile_id); +CREATE INDEX idx_wishlist_recipe ON wishlist(recipe_id); diff --git a/src/lib/server/wishlist/repository.ts b/src/lib/server/wishlist/repository.ts index a25b330..5520611 100644 --- a/src/lib/server/wishlist/repository.ts +++ b/src/lib/server/wishlist/repository.ts @@ -5,11 +5,10 @@ export type WishlistEntry = { title: string; image_path: string | null; source_domain: string | null; - added_by_profile_id: number | null; - added_by_name: string | null; - added_at: string; - like_count: number; - liked_by_me: 0 | 1; + added_at: string; // earliest per recipe + wanted_by_count: number; + wanted_by_names: string; // comma-joined profile names + on_my_wishlist: 0 | 1; avg_stars: number | null; }; @@ -21,9 +20,9 @@ export function listWishlist( sort: SortKey = 'popular' ): WishlistEntry[] { const orderBy = { - popular: 'like_count DESC, w.added_at DESC', - newest: 'w.added_at DESC', - oldest: 'w.added_at ASC' + popular: 'wanted_by_count DESC, first_added DESC', + newest: 'first_added DESC', + oldest: 'first_added ASC' }[sort]; return db @@ -33,66 +32,76 @@ export function listWishlist( r.title, r.image_path, r.source_domain, - w.added_by_profile_id, - p.name AS added_by_name, - w.added_at, - (SELECT COUNT(*) FROM wishlist_like wl WHERE wl.recipe_id = w.recipe_id) AS like_count, + MIN(w.added_at) AS first_added, + MIN(w.added_at) AS added_at, + COUNT(w.profile_id) AS wanted_by_count, + COALESCE(GROUP_CONCAT(p.name, ', '), '') AS wanted_by_names, CASE WHEN ? IS NULL THEN 0 - WHEN EXISTS (SELECT 1 FROM wishlist_like wl - WHERE wl.recipe_id = w.recipe_id AND wl.profile_id = ?) + WHEN EXISTS (SELECT 1 FROM wishlist w2 + WHERE w2.recipe_id = w.recipe_id AND w2.profile_id = ?) THEN 1 ELSE 0 - END AS liked_by_me, + END AS on_my_wishlist, (SELECT AVG(stars) FROM rating WHERE recipe_id = w.recipe_id) AS avg_stars FROM wishlist w JOIN recipe r ON r.id = w.recipe_id - LEFT JOIN profile p ON p.id = w.added_by_profile_id + LEFT JOIN profile p ON p.id = w.profile_id + GROUP BY w.recipe_id ORDER BY ${orderBy}` ) .all(activeProfileId, activeProfileId) as WishlistEntry[]; } -export function isOnWishlist(db: Database.Database, recipeId: number): boolean { +export function listWishlistProfileIds( + db: Database.Database, + recipeId: number +): number[] { return ( db - .prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ?') - .get(recipeId) !== undefined - ); + .prepare('SELECT profile_id FROM wishlist WHERE recipe_id = ?') + .all(recipeId) as { profile_id: number }[] + ).map((r) => r.profile_id); +} + +export function countWishlistRecipes(db: Database.Database): number { + const row = db + .prepare('SELECT COUNT(DISTINCT recipe_id) AS n FROM wishlist') + .get() as { n: number }; + return row.n; } export function addToWishlist( db: Database.Database, recipeId: number, - profileId: number | null + profileId: number ): void { db.prepare( - `INSERT INTO wishlist(recipe_id, added_by_profile_id) + `INSERT INTO wishlist(recipe_id, profile_id) VALUES (?, ?) - ON CONFLICT(recipe_id) DO NOTHING` + ON CONFLICT(recipe_id, profile_id) DO NOTHING` ).run(recipeId, profileId); } -export function removeFromWishlist(db: Database.Database, recipeId: number): void { - db.prepare('DELETE FROM wishlist WHERE recipe_id = ?').run(recipeId); -} - -export function likeWish( +export function removeFromWishlist( db: Database.Database, recipeId: number, profileId: number ): void { - db.prepare( - 'INSERT OR IGNORE INTO wishlist_like(recipe_id, profile_id) VALUES (?, ?)' - ).run(recipeId, profileId); + db.prepare('DELETE FROM wishlist WHERE recipe_id = ? AND profile_id = ?').run( + recipeId, + profileId + ); } -export function unlikeWish( +export function isOnMyWishlist( db: Database.Database, recipeId: number, profileId: number -): void { - db.prepare( - 'DELETE FROM wishlist_like WHERE recipe_id = ? AND profile_id = ?' - ).run(recipeId, profileId); +): boolean { + return ( + db + .prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ? AND profile_id = ?') + .get(recipeId, profileId) !== undefined + ); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e38e525..9a029f9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { goto, afterNavigate } from '$app/navigation'; import { Heart, Settings, CookingPot, Globe, Utensils } from 'lucide-svelte'; import { profileStore } from '$lib/client/profile.svelte'; + import { wishlistStore } from '$lib/client/wishlist.svelte'; import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import SearchLoader from '$lib/components/SearchLoader.svelte'; @@ -105,6 +106,7 @@ onMount(() => { profileStore.load(); + void wishlistStore.refresh(); document.addEventListener('click', handleClickOutside); document.addEventListener('keydown', handleKey); return () => { @@ -211,8 +213,17 @@ {/if}
- + 0 + ? `Wunschliste (${wishlistStore.count})` + : 'Wunschliste'} + > + {#if wishlistStore.count > 0} + {wishlistStore.count} + {/if} @@ -399,10 +410,28 @@ border-radius: 999px; text-decoration: none; font-size: 1.15rem; + position: relative; } .nav-link:hover { background: #f4f8f5; } + .badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #c53030; + color: white; + font-size: 0.7rem; + font-weight: 700; + line-height: 18px; + text-align: center; + box-shadow: 0 0 0 2px white; + pointer-events: none; + } main { padding: 0 1rem 4rem; max-width: 760px; diff --git a/src/routes/api/wishlist/+server.ts b/src/routes/api/wishlist/+server.ts index c6f39ad..871cc90 100644 --- a/src/routes/api/wishlist/+server.ts +++ b/src/routes/api/wishlist/+server.ts @@ -10,7 +10,7 @@ import { const AddSchema = z.object({ recipe_id: z.number().int().positive(), - profile_id: z.number().int().positive().nullable().optional() + profile_id: z.number().int().positive() }); const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const; @@ -34,7 +34,7 @@ export const GET: RequestHandler = async ({ url }) => { export const POST: RequestHandler = async ({ request }) => { const body = await request.json().catch(() => null); const parsed = AddSchema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id ?? null); + if (!parsed.success) error(400, { message: 'recipe_id and profile_id required' }); + addToWishlist(getDb(), parsed.data.recipe_id, parsed.data.profile_id); return json({ ok: true }, { status: 201 }); }; diff --git a/src/routes/api/wishlist/[recipe_id]/+server.ts b/src/routes/api/wishlist/[recipe_id]/+server.ts index 41eb5da..6939d4a 100644 --- a/src/routes/api/wishlist/[recipe_id]/+server.ts +++ b/src/routes/api/wishlist/[recipe_id]/+server.ts @@ -3,14 +3,15 @@ import { json, error } from '@sveltejs/kit'; import { getDb } from '$lib/server/db'; import { removeFromWishlist } from '$lib/server/wishlist/repository'; -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' }); - return id; +function parsePositiveInt(raw: string | null, field: string): number { + const n = raw === null ? NaN : Number(raw); + if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` }); + return n; } -export const DELETE: RequestHandler = async ({ params }) => { - const id = parseId(params.recipe_id!); - removeFromWishlist(getDb(), id); +export const DELETE: RequestHandler = async ({ params, url }) => { + const id = parsePositiveInt(params.recipe_id!, 'recipe_id'); + const profileId = parsePositiveInt(url.searchParams.get('profile_id'), 'profile_id'); + removeFromWishlist(getDb(), id, profileId); return json({ ok: true }); }; diff --git a/src/routes/api/wishlist/[recipe_id]/like/+server.ts b/src/routes/api/wishlist/[recipe_id]/like/+server.ts deleted file mode 100644 index 2b7048e..0000000 --- a/src/routes/api/wishlist/[recipe_id]/like/+server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RequestHandler } from './$types'; -import { json, error } from '@sveltejs/kit'; -import { z } from 'zod'; -import { getDb } from '$lib/server/db'; -import { likeWish, unlikeWish } from '$lib/server/wishlist/repository'; - -const Schema = z.object({ profile_id: z.number().int().positive() }); - -function parseId(raw: string): number { - const id = Number(raw); - if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid recipe_id' }); - return id; -} - -export const PUT: RequestHandler = async ({ params, request }) => { - const id = parseId(params.recipe_id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - likeWish(getDb(), id, parsed.data.profile_id); - return json({ ok: true }); -}; - -export const DELETE: RequestHandler = async ({ params, request }) => { - const id = parseId(params.recipe_id!); - const body = await request.json().catch(() => null); - const parsed = Schema.safeParse(body); - if (!parsed.success) error(400, { message: 'Invalid body' }); - unlikeWish(getDb(), id, parsed.data.profile_id); - return json({ ok: true }); -}; diff --git a/src/routes/api/wishlist/count/+server.ts b/src/routes/api/wishlist/count/+server.ts new file mode 100644 index 0000000..7cfaa23 --- /dev/null +++ b/src/routes/api/wishlist/count/+server.ts @@ -0,0 +1,8 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { getDb } from '$lib/server/db'; +import { countWishlistRecipes } from '$lib/server/wishlist/repository'; + +export const GET: RequestHandler = async () => { + return json({ count: countWishlistRecipes(getDb()) }); +}; diff --git a/src/routes/recipes/[id]/+page.server.ts b/src/routes/recipes/[id]/+page.server.ts index 969a987..aafa6ec 100644 --- a/src/routes/recipes/[id]/+page.server.ts +++ b/src/routes/recipes/[id]/+page.server.ts @@ -8,6 +8,7 @@ import { listFavoriteProfiles, listRatings } from '$lib/server/recipes/actions'; +import { listWishlistProfileIds } from '$lib/server/wishlist/repository'; export const load: PageServerLoad = async ({ params }) => { const id = Number(params.id); @@ -19,7 +20,16 @@ export const load: PageServerLoad = async ({ params }) => { const comments = listComments(db, id); const cooking_log = listCookingLog(db, id); const favorite_profile_ids = listFavoriteProfiles(db, id); + const wishlist_profile_ids = listWishlistProfileIds(db, id); const avg_stars = ratings.length === 0 ? null : ratings.reduce((s, r) => s + r.stars, 0) / ratings.length; - return { recipe, ratings, comments, cooking_log, favorite_profile_ids, avg_stars }; + return { + recipe, + ratings, + comments, + cooking_log, + favorite_profile_ids, + wishlist_profile_ids, + avg_stars + }; }; diff --git a/src/routes/recipes/[id]/+page.svelte b/src/routes/recipes/[id]/+page.svelte index 488d703..83e2334 100644 --- a/src/routes/recipes/[id]/+page.svelte +++ b/src/routes/recipes/[id]/+page.svelte @@ -14,6 +14,7 @@ import RecipeView from '$lib/components/RecipeView.svelte'; import StarRating from '$lib/components/StarRating.svelte'; import { profileStore } from '$lib/client/profile.svelte'; + import { wishlistStore } from '$lib/client/wishlist.svelte'; import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import type { CommentRow } from '$lib/server/recipes/actions'; @@ -24,7 +25,7 @@ let comments = $state([]); let cookingLog = $state([]); let favoriteProfileIds = $state([]); - let onWishlist = $state(false); + let wishlistProfileIds = $state([]); let newComment = $state(''); let title = $state(''); @@ -37,6 +38,7 @@ comments = [...data.comments]; cookingLog = [...data.cooking_log]; favoriteProfileIds = [...data.favorite_profile_ids]; + wishlistProfileIds = [...data.wishlist_profile_ids]; title = data.recipe.title; }); @@ -50,6 +52,10 @@ profileStore.active ? favoriteProfileIds.includes(profileStore.active.id) : false ); + const onMyWishlist = $derived( + profileStore.active ? wishlistProfileIds.includes(profileStore.active.id) : false + ); + async function setRating(stars: number) { if (!profileStore.active) { await alertAction({ @@ -195,27 +201,28 @@ } async function toggleWishlist() { - if (onWishlist) { - await fetch(`/api/wishlist/${data.recipe.id}`, { method: 'DELETE' }); - onWishlist = false; + if (!profileStore.active) { + await alertAction({ + title: 'Kein Profil gewählt', + message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.' + }); + return; + } + const profileId = profileStore.active.id; + if (onMyWishlist) { + await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, { + method: 'DELETE' + }); + wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId); } else { await fetch('/api/wishlist', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - recipe_id: data.recipe.id, - profile_id: profileStore.active?.id ?? null - }) + body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId }) }); - onWishlist = true; + wishlistProfileIds = [...wishlistProfileIds, profileId]; } - } - - async function refreshWishlistState() { - const res = await fetch('/api/wishlist?sort=newest'); - if (!res.ok) return; - const body = await res.json(); - onWishlist = body.entries.some((e: { recipe_id: number }) => e.recipe_id === data.recipe.id); + void wishlistStore.refresh(); } // Wake-Lock @@ -232,7 +239,6 @@ onMount(() => { void requestWakeLock(); - void refreshWishlistState(); const onVisibility = () => { if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock(); }; @@ -286,8 +292,8 @@ Favorit -