3 Commits

Author SHA1 Message Date
3b1950713f feat(ui): wishlist page, recipe toggle button, header link
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s
- /wishlist renders cards with avatar-badge of who added it, like count,
  heart toggle for active profile, delete button. Sort dropdown switches
  between popular / newest / oldest.
- /recipes/[id] gets 'Auf Wunschliste (setzen)' button alongside favorite.
- Layout header shows 🍽️ link to /wishlist next to the admin ⚙️.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
28e40d763d feat(api): wishlist endpoints (list, add, remove, like, unlike)
GET /api/wishlist?sort=popular|newest|oldest&profile_id=…
POST /api/wishlist { recipe_id, profile_id? }
DELETE /api/wishlist/[recipe_id]
PUT    /api/wishlist/[recipe_id]/like { profile_id }
DELETE /api/wishlist/[recipe_id]/like { profile_id }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
18547a7301 feat(wishlist): add shared family wishlist with likes
Each recipe appears at most once on the wishlist. Any profile can add,
remove, like, and unlike. Ratings and cooking log stay independent.

Data model: wishlist(recipe_id PK, added_by_profile_id, added_at)
            wishlist_like(recipe_id, profile_id, created_at)

Why: 'das will ich essen' — family members pick candidates, everyone
can +1 to signal agreement, cook decides based on popularity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:08:22 +02:00
10 changed files with 642 additions and 5 deletions

View File

@@ -0,0 +1,19 @@
-- Shared family wishlist: recipes someone wants to cook next.
-- Each recipe appears at most once; anyone can add/remove and like/unlike.
CREATE TABLE IF NOT EXISTS wishlist (
recipe_id INTEGER PRIMARY KEY REFERENCES recipe(id) ON DELETE CASCADE,
added_by_profile_id INTEGER REFERENCES profile(id) ON DELETE SET NULL,
added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_wishlist_added_at ON wishlist(added_at DESC);
CREATE TABLE IF NOT EXISTS wishlist_like (
recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (recipe_id, profile_id)
);
CREATE INDEX IF NOT EXISTS ix_wishlist_like_recipe ON wishlist_like(recipe_id);

View File

@@ -0,0 +1,98 @@
import type Database from 'better-sqlite3';
export type WishlistEntry = {
recipe_id: number;
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;
avg_stars: number | null;
};
export type SortKey = 'popular' | 'newest' | 'oldest';
export function listWishlist(
db: Database.Database,
activeProfileId: number | null,
sort: SortKey = 'popular'
): WishlistEntry[] {
const orderBy = {
popular: 'like_count DESC, w.added_at DESC',
newest: 'w.added_at DESC',
oldest: 'w.added_at ASC'
}[sort];
return db
.prepare(
`SELECT
w.recipe_id,
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,
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 = ?)
THEN 1
ELSE 0
END AS liked_by_me,
(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
ORDER BY ${orderBy}`
)
.all(activeProfileId, activeProfileId) as WishlistEntry[];
}
export function isOnWishlist(db: Database.Database, recipeId: number): boolean {
return (
db
.prepare('SELECT 1 AS ok FROM wishlist WHERE recipe_id = ?')
.get(recipeId) !== undefined
);
}
export function addToWishlist(
db: Database.Database,
recipeId: number,
profileId: number | null
): void {
db.prepare(
`INSERT INTO wishlist(recipe_id, added_by_profile_id)
VALUES (?, ?)
ON CONFLICT(recipe_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(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare(
'INSERT OR IGNORE INTO wishlist_like(recipe_id, profile_id) VALUES (?, ?)'
).run(recipeId, profileId);
}
export function unlikeWish(
db: Database.Database,
recipeId: number,
profileId: number
): void {
db.prepare(
'DELETE FROM wishlist_like WHERE recipe_id = ? AND profile_id = ?'
).run(recipeId, profileId);
}

View File

@@ -13,7 +13,8 @@
<header class="bar">
<a href="/" class="brand">Kochwas</a>
<div class="bar-right">
<a href="/admin" class="admin-link" aria-label="Einstellungen"></a>
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
<ProfileSwitcher />
</div>
</header>
@@ -59,7 +60,7 @@
align-items: center;
gap: 0.5rem;
}
.admin-link {
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -69,7 +70,7 @@
text-decoration: none;
font-size: 1.15rem;
}
.admin-link:hover {
.nav-link:hover {
background: #f4f8f5;
}
main {

View File

@@ -0,0 +1,40 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { z } from 'zod';
import { getDb } from '$lib/server/db';
import {
addToWishlist,
listWishlist,
type SortKey
} from '$lib/server/wishlist/repository';
const AddSchema = z.object({
recipe_id: z.number().int().positive(),
profile_id: z.number().int().positive().nullable().optional()
});
const VALID_SORTS: readonly SortKey[] = ['popular', 'newest', 'oldest'] as const;
function parseSort(raw: string | null): SortKey {
return VALID_SORTS.includes(raw as SortKey) ? (raw as SortKey) : 'popular';
}
function parseProfileId(raw: string | null): number | null {
if (!raw) return null;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
export const GET: RequestHandler = async ({ url }) => {
const sort = parseSort(url.searchParams.get('sort'));
const profileId = parseProfileId(url.searchParams.get('profile_id'));
return json({ sort, entries: listWishlist(getDb(), profileId, sort) });
};
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);
return json({ ok: true }, { status: 201 });
};

View File

@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
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;
}
export const DELETE: RequestHandler = async ({ params }) => {
const id = parseId(params.recipe_id!);
removeFromWishlist(getDb(), id);
return json({ ok: true });
};

View File

@@ -0,0 +1,31 @@
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 });
};

View File

@@ -13,6 +13,7 @@
let comments = $state<CommentRow[]>([]);
let cookingLog = $state<typeof data.cooking_log>([]);
let isFav = $state(false);
let onWishlist = $state(false);
let newComment = $state('');
$effect(() => {
@@ -124,6 +125,31 @@
location.reload();
}
async function toggleWishlist() {
if (onWishlist) {
await fetch(`/api/wishlist/${data.recipe.id}`, { method: 'DELETE' });
onWishlist = false;
} 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
})
});
onWishlist = true;
}
}
async function refreshWishlistState() {
// No dedicated GET for a single entry; scan the list and check.
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);
}
// Wake-Lock
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
@@ -139,6 +165,7 @@
onMount(() => {
void requestWakeLock();
void checkFavorite();
void refreshWishlistState();
const onVisibility = () => {
if (document.visibilityState === 'visible' && !wakeLock) void requestWakeLock();
};
@@ -171,6 +198,9 @@
<button class="btn" class:heart={isFav} onclick={toggleFavorite}>
{isFav ? '♥' : '♡'} Favorit
</button>
<button class="btn" class:wish={onWishlist} onclick={toggleWishlist}>
{onWishlist ? '✓' : '🍽️'} {onWishlist ? 'Auf Wunschliste' : 'Auf Wunschliste setzen'}
</button>
<button class="btn" onclick={() => window.print()}>🖨 Drucken</button>
<button class="btn" onclick={renameRecipe}> Umbenennen</button>
<button class="btn danger" onclick={deleteRecipe}>🗑 Löschen</button>
@@ -267,6 +297,11 @@
border-color: #f1b4b4;
background: #fdf3f3;
}
.btn.wish {
color: #2b6a3d;
border-color: #b7d6c2;
background: #eaf4ed;
}
.btn.primary {
background: #2b6a3d;
color: white;

View File

@@ -0,0 +1,259 @@
<script lang="ts">
import { onMount } from 'svelte';
import { profileStore } from '$lib/client/profile.svelte';
import type { WishlistEntry, SortKey } from '$lib/server/wishlist/repository';
let entries = $state<WishlistEntry[]>([]);
let loading = $state(true);
let sort = $state<SortKey>('popular');
async function load() {
loading = true;
const params = new URLSearchParams({ sort });
if (profileStore.active) params.set('profile_id', String(profileStore.active.id));
const res = await fetch(`/api/wishlist?${params}`);
const body = await res.json();
entries = body.entries;
loading = false;
}
$effect(() => {
// Re-fetch when sort or active profile changes
sort;
profileStore.activeId;
void load();
});
async function toggleLike(entry: WishlistEntry) {
if (!profileStore.active) {
alert('Bitte Profil wählen, um zu liken.');
return;
}
const method = entry.liked_by_me ? 'DELETE' : 'PUT';
await fetch(`/api/wishlist/${entry.recipe_id}/like`, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id })
});
await load();
}
async function remove(entry: WishlistEntry) {
if (!confirm(`„${entry.title}" von der Wunschliste entfernen?`)) return;
await fetch(`/api/wishlist/${entry.recipe_id}`, { method: 'DELETE' });
await load();
}
onMount(load);
function resolveImage(p: string | null): string | null {
if (!p) return null;
return /^https?:\/\//i.test(p) ? p : `/images/${p}`;
}
</script>
<header class="head">
<h1>Wunschliste</h1>
<p class="sub">Das wollen wir bald mal essen.</p>
</header>
<div class="controls">
<label>
Sortieren:
<select bind:value={sort}>
<option value="popular">Am meisten gewünscht</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</label>
</div>
{#if loading}
<p class="muted">Lädt …</p>
{:else if entries.length === 0}
<section class="empty">
<p class="big">🥘</p>
<p>Noch nichts gewünscht.</p>
<p class="hint">Öffne ein Rezept und klick dort auf „Auf Wunschliste".</p>
</section>
{:else}
<ul class="list">
{#each entries as e (e.recipe_id)}
<li class="card">
<a class="body" href={`/recipes/${e.recipe_id}`}>
{#if resolveImage(e.image_path)}
<img src={resolveImage(e.image_path)} alt="" loading="lazy" />
{:else}
<div class="placeholder">🥘</div>
{/if}
<div class="text">
<div class="title">{e.title}</div>
<div class="meta">
{#if e.added_by_name}
<span>von {e.added_by_name}</span>
{/if}
{#if e.source_domain}
<span>· {e.source_domain}</span>
{/if}
{#if e.avg_stars !== null}
<span>· ★ {e.avg_stars.toFixed(1)}</span>
{/if}
</div>
</div>
</a>
<div class="actions">
<button
class="like"
class:active={e.liked_by_me}
aria-label={e.liked_by_me ? 'Unlike' : 'Like'}
onclick={() => toggleLike(e)}
>
{e.liked_by_me ? '♥' : '♡'}
{#if e.like_count > 0}
<span class="count">{e.like_count}</span>
{/if}
</button>
<button class="del" aria-label="Entfernen" onclick={() => remove(e)}>🗑</button>
</div>
</li>
{/each}
</ul>
{/if}
<style>
.head {
padding: 1.25rem 0 0.5rem;
}
.head h1 {
margin: 0;
font-size: 1.6rem;
color: #2b6a3d;
}
.sub {
margin: 0.2rem 0 0;
color: #666;
}
.controls {
display: flex;
justify-content: flex-end;
padding: 0.5rem 0 1rem;
}
.controls label {
display: inline-flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
color: #555;
}
.controls select {
padding: 0.5rem 0.75rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
min-height: 40px;
background: white;
}
.muted {
color: #888;
text-align: center;
padding: 2rem 0;
}
.empty {
text-align: center;
padding: 3rem 1rem;
}
.big {
font-size: 3rem;
margin: 0 0 0.5rem;
}
.hint {
color: #888;
font-size: 0.9rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
display: flex;
align-items: stretch;
background: white;
border: 1px solid #e4eae7;
border-radius: 14px;
overflow: hidden;
min-height: 96px;
}
.body {
flex: 1;
display: flex;
gap: 0.75rem;
text-decoration: none;
color: inherit;
min-width: 0;
}
.body img,
.placeholder {
width: 96px;
object-fit: cover;
background: #eef3ef;
display: grid;
place-items: center;
font-size: 2rem;
flex-shrink: 0;
}
.text {
flex: 1;
padding: 0.7rem 0.75rem;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.title {
font-weight: 600;
font-size: 1rem;
line-height: 1.3;
}
.meta {
display: flex;
gap: 0.3rem;
margin-top: 0.25rem;
color: #888;
font-size: 0.82rem;
flex-wrap: wrap;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.6rem 0.5rem 0;
justify-content: center;
}
.like,
.del {
min-width: 48px;
min-height: 44px;
border-radius: 10px;
border: 1px solid #e4eae7;
background: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
font-size: 1.05rem;
}
.like.active {
color: #c53030;
background: #fdf3f3;
border-color: #f1b4b4;
}
.count {
font-size: 0.85rem;
font-weight: 600;
}
</style>

View File

@@ -30,9 +30,14 @@ describe('db migrations', () => {
it('is idempotent', () => {
const db = openInMemoryForTest();
const countBefore = (
db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
).c;
runMigrations(db);
const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number };
expect(migs.c).toBe(1);
const countAfter = (
db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }
).c;
expect(countAfter).toBe(countBefore);
});
it('cascades recipe delete to ingredients and steps', () => {

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { openInMemoryForTest } from '../../src/lib/server/db';
import { createProfile } from '../../src/lib/server/profiles/repository';
import { insertRecipe } from '../../src/lib/server/recipes/repository';
import {
addToWishlist,
removeFromWishlist,
listWishlist,
isOnWishlist,
likeWish,
unlikeWish
} from '../../src/lib/server/wishlist/repository';
import type { Recipe } from '../../src/lib/types';
const recipe = (title: string, id?: null): Recipe => ({
id: id ?? null,
title,
description: null,
source_url: null,
source_domain: null,
image_path: null,
servings_default: 4,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [],
steps: [],
tags: []
});
let db: Database.Database;
beforeEach(() => {
db = openInMemoryForTest();
});
describe('wishlist add/remove', () => {
it('adds and lists', () => {
const r1 = insertRecipe(db, recipe('Carbonara'));
const p = createProfile(db, 'Hendrik');
addToWishlist(db, r1, p.id);
expect(isOnWishlist(db, r1)).toBe(true);
const list = listWishlist(db, p.id);
expect(list.length).toBe(1);
expect(list[0].title).toBe('Carbonara');
expect(list[0].added_by_name).toBe('Hendrik');
});
it('is idempotent on double-add', () => {
const r1 = insertRecipe(db, recipe('Pizza'));
const p = createProfile(db, 'A');
addToWishlist(db, r1, p.id);
addToWishlist(db, r1, p.id);
expect(listWishlist(db, p.id).length).toBe(1);
});
it('removes', () => {
const r1 = insertRecipe(db, recipe('X'));
addToWishlist(db, r1, null);
removeFromWishlist(db, r1);
expect(listWishlist(db, null).length).toBe(0);
});
it('cascades with recipe delete', () => {
const r1 = insertRecipe(db, recipe('X'));
addToWishlist(db, r1, null);
db.prepare('DELETE FROM recipe WHERE id = ?').run(r1);
expect(listWishlist(db, null).length).toBe(0);
});
});
describe('wishlist likes + sort', () => {
it('counts likes per entry and shows liked_by_me for active profile', () => {
const r1 = insertRecipe(db, recipe('R1'));
const r2 = insertRecipe(db, recipe('R2'));
const a = createProfile(db, 'A');
const b = createProfile(db, 'B');
const c = createProfile(db, 'C');
addToWishlist(db, r1, a.id);
addToWishlist(db, r2, a.id);
likeWish(db, r1, a.id);
likeWish(db, r1, b.id);
likeWish(db, r1, c.id);
likeWish(db, r2, a.id);
const listA = listWishlist(db, a.id, 'popular');
expect(listA[0].title).toBe('R1');
expect(listA[0].like_count).toBe(3);
expect(listA[0].liked_by_me).toBe(1);
expect(listA[1].title).toBe('R2');
expect(listA[1].like_count).toBe(1);
const listB = listWishlist(db, b.id);
expect(listB.find((e) => e.recipe_id === r1)!.liked_by_me).toBe(1);
expect(listB.find((e) => e.recipe_id === r2)!.liked_by_me).toBe(0);
});
it('unlike is idempotent and decrements count', () => {
const r = insertRecipe(db, recipe('R'));
const a = createProfile(db, 'A');
addToWishlist(db, r, a.id);
likeWish(db, r, a.id);
unlikeWish(db, r, a.id);
unlikeWish(db, r, a.id);
const [entry] = listWishlist(db, a.id);
expect(entry.like_count).toBe(0);
expect(entry.liked_by_me).toBe(0);
});
it('sort=newest orders by added_at desc, oldest asc', () => {
const r1 = insertRecipe(db, recipe('First'));
// Force different timestamps via raw insert with explicit added_at
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-01 10:00:00')").run(r1);
const r2 = insertRecipe(db, recipe('Second'));
db.prepare("INSERT INTO wishlist(recipe_id, added_at) VALUES (?, '2026-01-02 10:00:00')").run(r2);
expect(listWishlist(db, null, 'newest').map((e) => e.title)).toEqual(['Second', 'First']);
expect(listWishlist(db, null, 'oldest').map((e) => e.title)).toEqual(['First', 'Second']);
});
it('handles anonymous (no active profile) — liked_by_me always 0', () => {
const r = insertRecipe(db, recipe('R'));
addToWishlist(db, r, null);
likeWish(db, r, createProfile(db, 'A').id);
const [entry] = listWishlist(db, null);
expect(entry.like_count).toBe(1);
expect(entry.liked_by_me).toBe(0);
});
});