Compare commits

..

3 Commits

Author SHA1 Message Date
Arnaud aa6bd03c5e feat: push notification when a shared shopping list is updated
Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.

Deliberately skips the notifications table/notification-center (same
pattern as the existing leftover-expiry cron) — this is a lightweight
best-effort ping, not a persistent event, so it avoids a notification_type
enum migration and a new FK column for something transient.

Verified locally: added a real member to a shared list, registered a
push subscription, confirmed the check-off request's added latency
(945ms vs ~20ms baseline) shows the actual webpush call fired against
the recipient — individual subscription failures are swallowed by design
(Promise.allSettled in the existing sendPushNotification, unchanged);
confirmed a solo list stays fast (no recipients to notify, true no-op).
2026-07-12 18:36:20 +02:00
Arnaud 9566e19cd0 fix: weekly meal-plan nutrition compared a week's total against a daily goal
userNutritionGoals are daily targets (the nutrition diary already compares
a single day's totals against them directly) — but the weekly meal-plan
route summed an entire week's entries and compared that raw total straight
against the same daily number, so coverage read roughly 7x too high.

Now computes a daily average (across days that actually have planned
meals) and compares that against the goal instead, adds a per-day
breakdown (byDay) for a future day-view, and tracks unknownCount for
entries whose recipe has no nutrition data yet, matching the diary route.

Also fixed a real bug this surfaced: meal-plan/page.tsx and print/meal-plan
parsed the ?week= date via new Date(dateStr) (UTC midnight) then read
.getDay() (local time) — in positive-UTC-offset timezones this resolves to
the wrong Monday, and the reverse (Date -> string via toISOString()) has
the same mismatch in the other direction. Both now do local y/m/d math
throughout.

Verified locally: correct daily-average math end to end (recipe entry +
batch-dish entry, which already attributed nutrition correctly via its
required parent recipeId — no separate fix needed there), and confirmed
the meal-plan page now resolves the right week and renders real coverage
bars against actual planned entries.
2026-07-12 18:35:58 +02:00
Arnaud a9dc1b63c1 fix: batch-cook dishes never deducted pantry when marked cooked
Ingredients aren't attributable to individual batch dishes (they're one
merged/shared list for the whole prep session, unlike steps which have
`appliesTo`) — the cooked route's pantry-deduction block was unconditionally
skipped whenever batchDishId was set, so pantry was never touched for any
batch-cook recipe.

Deducts once, on the first dish marked cooked for that recipe+user (checked
via prior cookingHistory rows) — matches the fridge/freezer-day design
intent of "cook the batch once, eat dishes over several days" rather than
deducting (or trying to deduct) per individual dish.

Verified locally: first dish cooked deducted the full recipe's ingredients
from pantry; second dish cooked did not double-deduct.
2026-07-12 18:35:32 +02:00
10 changed files with 245 additions and 59 deletions
+18 -4
View File
@@ -17,16 +17,30 @@ import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {};
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
// new Date("YYYY-MM-DD") parses as UTC midnight, but getDay() below reads
// local time — in negative UTC-offset zones that's still "yesterday",
// shifting the resolved Monday back by a full week. Parse the y/m/d parts
// directly into local time instead.
const d = dateStr
? (() => {
const [y, m, day] = dateStr.split("-").map(Number);
return new Date(y!, m! - 1, day!);
})()
: new Date();
const dow = d.getDay();
const diff = (dow === 0 ? -6 : 1 - dow);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
// Read local y/m/d, not toISOString() (which converts to UTC and can
// shift the date by a day depending on the server's timezone offset).
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function addWeeks(d: Date, n: number): Date {
@@ -1,9 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, mealPlanEntries, recipes, userNutritionGoals, eq, and } from "@epicure/db";
import { db, mealPlans, mealPlanEntries, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ weekStart: string }> };
type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
type Totals = { calories: number; protein: number; carbs: number; fat: number };
function emptyTotals(): Totals {
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
}
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
@@ -11,75 +19,95 @@ export async function GET(_req: NextRequest, { params }: Params) {
const { weekStart } = await params;
const userId = session!.user.id;
// Find the meal plan for this week
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
});
const totals = { calories: 0, protein: 0, carbs: 0, fat: 0 };
const weekTotals = emptyTotals();
const byDay: Record<Weekday, Totals> = {
mon: emptyTotals(), tue: emptyTotals(), wed: emptyTotals(), thu: emptyTotals(),
fri: emptyTotals(), sat: emptyTotals(), sun: emptyTotals(),
};
let unknownCount = 0;
let plannedDays = 0;
if (plan) {
// Fetch all entries with their recipes
// batchDishId entries always carry their parent recipeId too (enforced at
// entry-creation time), so joining on `recipe` already covers batch
// dishes — there's no separate per-dish nutrition data to look up.
const entries = await db.query.mealPlanEntries.findMany({
where: eq(mealPlanEntries.mealPlanId, plan.id),
with: {
recipe: {
columns: {
id: true,
baseServings: true,
nutritionData: true,
},
columns: { id: true, baseServings: true, nutritionData: true },
},
},
});
const daysWithEntries = new Set<string>();
for (const entry of entries) {
daysWithEntries.add(entry.day);
const recipe = entry.recipe;
if (!recipe || !recipe.nutritionData?.perServing) continue;
if (!recipe || !recipe.nutritionData?.perServing) {
unknownCount++;
continue;
}
const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing;
const servings = entry.servings ?? recipe.baseServings;
const day = byDay[entry.day as Weekday];
totals.calories += Math.round(calories * servings);
totals.protein += Math.round(proteinG * servings);
totals.carbs += Math.round(carbsG * servings);
totals.fat += Math.round(fatG * servings);
const cals = Math.round(calories * servings);
const protein = Math.round(proteinG * servings);
const carbs = Math.round(carbsG * servings);
const fat = Math.round(fatG * servings);
weekTotals.calories += cals;
weekTotals.protein += protein;
weekTotals.carbs += carbs;
weekTotals.fat += fat;
day.calories += cals;
day.protein += protein;
day.carbs += carbs;
day.fat += fat;
}
plannedDays = daysWithEntries.size;
}
// Fetch user's nutrition goals
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, userId),
});
const goalsData = goals
? {
caloriesKcal: goals.caloriesKcal,
proteinG: goals.proteinG,
carbsG: goals.carbsG,
fatG: goals.fatG,
}
? { caloriesKcal: goals.caloriesKcal, proteinG: goals.proteinG, carbsG: goals.carbsG, fatG: goals.fatG }
: null;
// Calculate coverage percentages
const coverage = {
calories:
goalsData?.caloriesKcal
? Math.round((totals.calories / goalsData.caloriesKcal) * 100)
: 0,
protein:
goalsData?.proteinG
? Math.round((totals.protein / goalsData.proteinG) * 100)
: 0,
carbs:
goalsData?.carbsG
? Math.round((totals.carbs / goalsData.carbsG) * 100)
: 0,
fat:
goalsData?.fatG
? Math.round((totals.fat / goalsData.fatG) * 100)
: 0,
// Goals are daily targets (see the nutrition diary, which compares a single
// day's totals against them directly) — a week's raw total is ~7x a daily
// goal, so coverage must compare against the daily AVERAGE across days that
// actually have planned meals, not the week's total.
const divisor = plannedDays || 1;
const dailyAverage: Totals = {
calories: Math.round(weekTotals.calories / divisor),
protein: Math.round(weekTotals.protein / divisor),
carbs: Math.round(weekTotals.carbs / divisor),
fat: Math.round(weekTotals.fat / divisor),
};
return NextResponse.json({ totals, goals: goalsData, coverage });
const coverage = {
calories: goalsData?.caloriesKcal ? Math.round((dailyAverage.calories / goalsData.caloriesKcal) * 100) : 0,
protein: goalsData?.proteinG ? Math.round((dailyAverage.protein / goalsData.proteinG) * 100) : 0,
carbs: goalsData?.carbsG ? Math.round((dailyAverage.carbs / goalsData.carbsG) * 100) : 0,
fat: goalsData?.fatG ? Math.round((dailyAverage.fat / goalsData.fatG) * 100) : 0,
};
return NextResponse.json({
totals: weekTotals,
dailyAverage,
byDay,
plannedDays,
unknownCount,
goals: goalsData,
coverage,
});
}
@@ -27,11 +27,21 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body);
const data = parsed.success ? parsed.data : { deductFromPantry: true };
// Ingredients aren't attributable to individual batch dishes — they're one
// merged/shared list for the whole prep session (unlike steps, which have
// `appliesTo`). So pantry deduction for a batch-cook recipe happens once,
// on the first dish marked cooked, rather than per-dish.
let isFirstBatchCook = false;
if (data.batchDishId) {
const dish = await db.query.recipeBatchDishes.findFirst({
where: and(eq(recipeBatchDishes.id, data.batchDishId), eq(recipeBatchDishes.recipeId, id)),
});
if (!dish) return NextResponse.json({ error: "Dish not found" }, { status: 404 });
const priorCook = await db.query.cookingHistory.findFirst({
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, userId)),
});
isFirstBatchCook = !priorCook;
}
await db.insert(cookingHistory).values({
@@ -44,11 +54,14 @@ export async function POST(req: NextRequest, { params }: Params) {
cookedAt: new Date(),
});
if (data.deductFromPantry && !data.batchDishId) {
if (data.deductFromPantry && (!data.batchDishId || isFirstBatchCook)) {
const ings = await db.query.recipeIngredients.findMany({
where: eq(recipeIngredients.recipeId, id),
});
const scale = data.servings ? data.servings / recipe.baseServings : 1;
// A batch session's merged ingredient list is deducted once as a whole,
// regardless of which single dish triggered the first cook — never
// scaled by that one dish's serving count.
const scale = data.batchDishId ? 1 : (data.servings ? data.servings / recipe.baseServings : 1);
const userPantry = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, userId),
});
@@ -3,6 +3,7 @@ import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
type Params = { params: Promise<{ id: string; itemId: string }> };
@@ -44,6 +45,23 @@ export async function PUT(req: NextRequest, { params }: Params) {
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
}
// Only checking an item OFF is worth pinging other members about (the
// "someone's shopping right now" coordination signal) — unchecking,
// renames, reordering, and aisle edits are too noisy for a push.
if (data.checked === true) {
const item = await db.query.shoppingListItems.findFirst({
where: eq(shoppingListItems.id, itemId),
columns: { rawName: true },
});
if (item) {
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
type: "checked",
itemName: item.rawName,
listName: access.list.name,
}).catch(() => {});
}
}
return NextResponse.json({ updated: true });
}
@@ -4,6 +4,7 @@ import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { guessAisle } from "@/lib/grocery-categories";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
const AddItemsSchema = z.object({
items: z.array(z.object({
@@ -53,6 +54,12 @@ export async function POST(req: NextRequest, { params }: Params) {
}))
);
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
type: "itemsAdded",
count: parsed.data.items.length,
listName: access.list.name,
}).catch(() => {});
return NextResponse.json({ ok: true }, { status: 201 });
}
+19 -4
View File
@@ -8,14 +8,29 @@ const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
// Parse y/m/d directly into local time — new Date("YYYY-MM-DD") parses as
// UTC midnight, which getDay() (local time) can read as the wrong weekday
// depending on the server's timezone offset.
const d = dateStr
? (() => {
const [y, m, day] = dateStr.split("-").map(Number);
return new Date(y!, m! - 1, day!);
})()
: new Date();
const dow = d.getDay();
const diff = dow === 0 ? -6 : 1 - dow;
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default async function MealPlanPrintPage({
searchParams,
}: {
@@ -28,7 +43,7 @@ export default async function MealPlanPrintPage({
const m = getMessages((session.user as { locale?: string }).locale);
const monday = getMonday(week);
const weekStart = monday.toISOString().slice(0, 10);
const weekStart = toDateStr(monday);
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
@@ -25,6 +25,8 @@ type NutritionCoverage = {
type NutritionResponse = {
totals: NutritionTotals;
dailyAverage: NutritionTotals;
unknownCount: number;
goals: NutritionGoals;
coverage: NutritionCoverage;
};
@@ -54,12 +56,12 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
if (!data || !data.goals) return null;
const { totals, goals, coverage } = data;
const { dailyAverage, goals, coverage, unknownCount } = data;
const bars: BarItem[] = [
{
label: "Calories",
value: totals.calories,
value: dailyAverage.calories,
goal: goals.caloriesKcal,
unit: "kcal",
percentage: coverage.calories,
@@ -67,7 +69,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
},
{
label: "Protein",
value: totals.protein,
value: dailyAverage.protein,
goal: goals.proteinG,
unit: "g",
percentage: coverage.protein,
@@ -75,7 +77,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
},
{
label: "Carbs",
value: totals.carbs,
value: dailyAverage.carbs,
goal: goals.carbsG,
unit: "g",
percentage: coverage.carbs,
@@ -83,7 +85,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
},
{
label: "Fat",
value: totals.fat,
value: dailyAverage.fat,
goal: goals.fatG,
unit: "g",
percentage: coverage.fat,
@@ -93,6 +95,7 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">Daily average vs. your goals</p>
{bars.map((bar) => {
if (bar.goal == null) return null;
const width = Math.min(bar.percentage, 100);
@@ -113,6 +116,11 @@ export function WeeklyNutritionBar({ weekStart }: WeeklyNutritionBarProps) {
</div>
);
})}
{unknownCount > 0 && (
<p className="text-xs text-muted-foreground">
{unknownCount} planned {unknownCount === 1 ? "meal" : "meals"} missing nutrition data estimate it from the recipe page for a more accurate picture.
</p>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { db, shoppingLists, shoppingListMembers, eq } from "@epicure/db";
import { sendPushNotification } from "@/lib/push";
import { getMessages, formatMessage } from "@/lib/i18n/server";
// formatMessage() is a plain {key} interpolator, not ICU-plural-aware, so
// pluralization has to be resolved to a plain string before it's passed in.
function itemsCountLabel(count: number, locale: string | null | undefined): string {
if (locale === "fr") return count === 1 ? "1 article" : `${count} articles`;
return count === 1 ? "1 item" : `${count} items`;
}
/** Other people with access to a shared shopping list — owner + members, excluding the actor. */
async function getOtherRecipients(listId: string, actorId: string) {
const list = await db.query.shoppingLists.findFirst({
where: eq(shoppingLists.id, listId),
columns: { userId: true },
});
const members = await db.query.shoppingListMembers.findMany({
where: eq(shoppingListMembers.listId, listId),
columns: { userId: true },
});
const recipientIds = new Set<string>();
if (list?.userId) recipientIds.add(list.userId);
for (const m of members) recipientIds.add(m.userId);
recipientIds.delete(actorId);
if (recipientIds.size === 0) return [];
return db.query.users.findMany({
where: (t, { inArray }) => inArray(t.id, [...recipientIds]),
columns: { id: true, locale: true },
});
}
/**
* Push-notifies every other member of a shared shopping list about an item
* change. No-op for solo (unshared) lists — getOtherRecipients returns empty.
* Deliberately skips the `notifications` table (no in-app entry) since this
* is meant as a lightweight real-time "someone's shopping too" ping, same
* pattern as the leftover-expiry cron — not a persistent notification-center
* event.
*/
export async function notifyShoppingListMembers(
listId: string,
actorId: string,
actorName: string,
event: { type: "checked" | "itemsAdded"; itemName?: string; count?: number; listName: string }
): Promise<void> {
const recipients = await getOtherRecipients(listId, actorId);
if (recipients.length === 0) return;
const url = `/shopping-lists/${listId}`;
await Promise.all(
recipients.map(async (recipient) => {
const messages = getMessages(recipient.locale);
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =
event.type === "checked"
? formatMessage(messages.notifications.detail.shoppingListChecked, {
name: actorName,
item: event.itemName ?? "",
list: event.listName,
})
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
name: actorName,
countLabel: itemsCountLabel(event.count ?? 1, recipient.locale),
list: event.listName,
});
await sendPushNotification(recipient.id, { title, body, url }).catch((err) => {
console.error("[shopping-list-notify] push failed", err);
});
})
);
}
+5 -2
View File
@@ -41,7 +41,9 @@
"detail": {
"comment": "{name} commented on your recipe \"{title}\"",
"rating": "{name} rated your recipe \"{title}\" {stars} stars",
"leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!"
"leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!",
"shoppingListChecked": "{name} checked off \"{item}\" in \"{list}\"",
"shoppingListItemsAdded": "{name} added {countLabel} to \"{list}\""
},
"pushTitle": {
"follow": "New follower",
@@ -50,7 +52,8 @@
"reaction": "New reaction",
"rating": "New rating",
"mention": "You were mentioned",
"leftoverExpiring": "Leftovers expiring soon"
"leftoverExpiring": "Leftovers expiring soon",
"shoppingListUpdate": "Shopping list update"
}
},
"recipe": {
+5 -2
View File
@@ -41,7 +41,9 @@
"detail": {
"comment": "{name} a commenté votre recette « {title} »",
"rating": "{name} a noté votre recette « {title} » {stars} étoiles",
"leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !"
"leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !",
"shoppingListChecked": "{name} a coché « {item} » dans « {list} »",
"shoppingListItemsAdded": "{name} a ajouté {countLabel} à « {list} »"
},
"pushTitle": {
"follow": "Nouvel abonné",
@@ -50,7 +52,8 @@
"reaction": "Nouvelle réaction",
"rating": "Nouvelle note",
"mention": "Vous avez été mentionné",
"leftoverExpiring": "Restes bientôt périmés"
"leftoverExpiring": "Restes bientôt périmés",
"shoppingListUpdate": "Mise à jour de la liste de courses"
}
},
"recipe": {