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>
This commit is contained in:
2026-04-17 17:08:22 +02:00
parent 72019f9cb7
commit 18547a7301
4 changed files with 257 additions and 2 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

@@ -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);
});
});