Compare commits
3 Commits
1bcea2f273
...
aa6bd03c5e
| Author | SHA1 | Date | |
|---|---|---|---|
| aa6bd03c5e | |||
| 9566e19cd0 | |||
| a9dc1b63c1 |
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user