feat(wishlist): per-user Wünsche + Header-Badge mit Gesamtzahl
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m16s
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: <distinct recipes> }
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.
This commit is contained in:
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
29
src/lib/server/db/migrations/005_wishlist_per_user.sql
Normal file
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user