diff --git a/.dockerignore b/.dockerignore
index f3b3b8e..6e57717 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,4 +6,5 @@
.env*
!.env.example
docker
+!docker/cron
*.md
diff --git a/.env.example b/.env.example
index de215bf..c578d1f 100644
--- a/.env.example
+++ b/.env.example
@@ -60,6 +60,10 @@ VAPID_PRIVATE_KEY=
# Stripe (optional — webhook stub only)
STRIPE_WEBHOOK_SECRET=
+# Shared secret for internal cron-triggered endpoints (e.g. weekly digest email).
+# Generate with: openssl rand -base64 32
+CRON_SECRET=
+
# AI Providers (configure at least one)
OPENROUTER_API_KEY=
OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5
diff --git a/Dockerfile b/Dockerfile
index 5ccd131..39ce4de 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -56,3 +56,14 @@ COPY --from=build --chown=nextjs:nodejs /repo/apps/web/.next/static ./apps/web/.
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]
+
+# ---- cron: periodically triggers internal cron endpoints (e.g. weekly digest) ----
+# Minimal alpine + busybox crond image — just curls the running `web` service on a
+# schedule. Doesn't need the app build output at all, so it's based on `base`
+# rather than `runner`, keeping the image tiny and independent of the Next build.
+FROM base AS cron
+RUN apk add --no-cache curl
+COPY docker/cron/crontab /etc/crontabs/root
+COPY docker/cron/run-digest.sh /usr/local/bin/run-digest.sh
+RUN chmod +x /usr/local/bin/run-digest.sh
+CMD ["crond", "-f", "-l", "2"]
diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx
index 331884f..87f47c8 100644
--- a/apps/web/app/(app)/meal-plan/page.tsx
+++ b/apps/web/app/(app)/meal-plan/page.tsx
@@ -3,7 +3,7 @@ import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
import { auth } from "@/lib/auth/server";
-import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db";
+import { db, mealPlans, mealPlanMembers, recipes, userNutritionGoals, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
@@ -52,7 +52,7 @@ export default async function MealPlanPage({
const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1);
- const [plan, userRecipes, sharedMemberships] = await Promise.all([
+ const [plan, userRecipes, sharedMemberships, nutritionGoals] = await Promise.all([
db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
@@ -70,8 +70,16 @@ export default async function MealPlanPage({
where: eq(mealPlanMembers.userId, session.user.id),
with: { mealPlan: { with: { user: true } } },
}),
+ db.query.userNutritionGoals.findFirst({
+ where: eq(userNutritionGoals.userId, session.user.id),
+ }),
]);
+ const hasNutritionGoals = !!(
+ nutritionGoals &&
+ (nutritionGoals.caloriesKcal || nutritionGoals.proteinG || nutritionGoals.carbsG || nutritionGoals.fatG)
+ );
+
const entries = (plan?.entries ?? []).map((e) => ({
id: e.id,
day: e.day,
@@ -114,7 +122,7 @@ export default async function MealPlanPage({
-
+
{sharedMemberships.length > 0 && (
diff --git a/apps/web/app/(app)/nutrition/page.tsx b/apps/web/app/(app)/nutrition/page.tsx
new file mode 100644
index 0000000..a22301f
--- /dev/null
+++ b/apps/web/app/(app)/nutrition/page.tsx
@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth/server";
+import { NutritionDiary } from "@/components/nutrition/nutrition-diary";
+import { getMessages } from "@/lib/i18n/server";
+
+export const metadata: Metadata = {};
+
+export default async function NutritionDiaryPage() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) return null;
+ const m = getMessages((session.user as { locale?: string }).locale);
+
+ return (
+
+
+
{m.nutritionDiary.title}
+
{m.nutritionDiary.subtitle}
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/pantry/page.tsx b/apps/web/app/(app)/pantry/page.tsx
index 48aaa2e..68de884 100644
--- a/apps/web/app/(app)/pantry/page.tsx
+++ b/apps/web/app/(app)/pantry/page.tsx
@@ -51,7 +51,7 @@ export default async function PantryPage() {
-
+
i.id).join(",")} initialItems={mappedItems} />
);
}
diff --git a/apps/web/app/(app)/recipes/[id]/cook/page.tsx b/apps/web/app/(app)/recipes/[id]/cook/page.tsx
index c08cd36..c60d20d 100644
--- a/apps/web/app/(app)/recipes/[id]/cook/page.tsx
+++ b/apps/web/app/(app)/recipes/[id]/cook/page.tsx
@@ -30,10 +30,13 @@ export default async function CookPage({ params }: Params) {
if (!recipe || recipe.steps.length === 0) notFound();
+ const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
+
return (
({
id: s.id,
instruction: s.instruction,
diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx
index 77d7e9c..414edfa 100644
--- a/apps/web/app/(app)/recipes/[id]/page.tsx
+++ b/apps/web/app/(app)/recipes/[id]/page.tsx
@@ -58,6 +58,7 @@ export default async function RecipePage({ params }: Params) {
const m = getMessages((session.user as { locale?: string }).locale);
const VISIBILITY_LABEL = m.recipe.visibility;
const DIETARY_LABELS = m.recipe.dietary;
+ const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote] = await Promise.all([
db.query.recipes.findFirst({
@@ -346,6 +347,7 @@ export default async function RecipePage({ params }: Params) {
({
id: ing.id,
rawName: ing.rawName,
diff --git a/apps/web/app/(app)/settings/nutrition/page.tsx b/apps/web/app/(app)/settings/nutrition/page.tsx
index c5a3389..8b1c4c8 100644
--- a/apps/web/app/(app)/settings/nutrition/page.tsx
+++ b/apps/web/app/(app)/settings/nutrition/page.tsx
@@ -1,9 +1,12 @@
import type { Metadata } from "next";
+import Link from "next/link";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userNutritionGoals, eq } from "@epicure/db";
import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form";
import { getMessages } from "@/lib/i18n/server";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
export const metadata: Metadata = {};
@@ -38,6 +41,15 @@ export default async function NutritionPage() {
}
/>
+
+
+
{m.nutritionDiary.title}
+
{m.nutritionDiary.subtitle}
+
+
+ {m.settings.nutritionGoals.viewDiaryCta}
+
+
);
}
diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx
index 9897135..dc7376a 100644
--- a/apps/web/app/(app)/u/[username]/page.tsx
+++ b/apps/web/app/(app)/u/[username]/page.tsx
@@ -9,10 +9,13 @@ import {
recipes,
userFollows,
userBlocks,
+ cookingHistory,
+ ratings,
eq,
and,
desc,
count,
+ isNotNull,
} from "@epicure/db";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -20,14 +23,32 @@ import { FollowButton } from "@/components/social/follow-button";
import { BlockButton } from "@/components/social/block-button";
import { MessageButton } from "@/components/social/message-button";
import { getPublicUrl } from "@/lib/storage";
+import { ProfileTabs, type HistoryData, type PhotosData } from "@/components/profile/profile-tabs";
const PAGE_SIZE = 24;
type Params = {
params: Promise<{ username: string }>;
- searchParams: Promise<{ page?: string }>;
+ searchParams: Promise<{ page?: string; tab?: string; ppage?: string }>;
};
+/** Consecutive-day streak (UTC calendar days) ending today or yesterday. */
+function computeStreak(dates: Date[]): number {
+ const daySet = new Set(dates.map((d) => d.toISOString().slice(0, 10)));
+ const cursor = new Date();
+ cursor.setUTCHours(0, 0, 0, 0);
+ if (!daySet.has(cursor.toISOString().slice(0, 10))) {
+ cursor.setUTCDate(cursor.getUTCDate() - 1);
+ if (!daySet.has(cursor.toISOString().slice(0, 10))) return 0;
+ }
+ let streak = 0;
+ while (daySet.has(cursor.toISOString().slice(0, 10))) {
+ streak++;
+ cursor.setUTCDate(cursor.getUTCDate() - 1);
+ }
+ return streak;
+}
+
export async function generateMetadata({ params }: Params) {
const { username } = await params;
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
@@ -36,9 +57,12 @@ export async function generateMetadata({ params }: Params) {
export default async function UserProfilePage({ params, searchParams }: Params) {
const { username } = await params;
- const { page: pageParam } = await searchParams;
+ const { page: pageParam, tab: tabParam, ppage: ppageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
+ const photoPage = Math.max(1, parseInt(ppageParam ?? "1", 10) || 1);
+ const photoOffset = (photoPage - 1) * PAGE_SIZE;
+ const activeTab = tabParam === "history" || tabParam === "photos" ? tabParam : "recipes";
const session = await auth.api.getSession({ headers: await headers() });
@@ -96,6 +120,89 @@ export default async function UserProfilePage({ params, searchParams }: Params)
isBlocked = !!blockRow;
}
+ // Cooking history and "cooked it" photo gallery are only meaningful (and visible) to the
+ // profile owner: history reveals private behavioral/timing patterns, and photos may be
+ // attached to reviews of recipes that aren't visible to other viewers (private recipes),
+ // so surfacing them to non-owners could leak the existence of content they can't access.
+ let historyData: HistoryData | null = null;
+ let photosData: PhotosData | null = null;
+
+ if (isOwnProfile) {
+ const [totalCookedRow, cookedDatesRows, lastCookedRow, recentEntries, totalPhotosRow, photoRows] =
+ await Promise.all([
+ db.select({ count: count() }).from(cookingHistory).where(eq(cookingHistory.userId, user.id)),
+ db.query.cookingHistory.findMany({
+ where: eq(cookingHistory.userId, user.id),
+ columns: { cookedAt: true },
+ orderBy: desc(cookingHistory.cookedAt),
+ limit: 3650,
+ }),
+ db.query.cookingHistory.findFirst({
+ where: eq(cookingHistory.userId, user.id),
+ orderBy: desc(cookingHistory.cookedAt),
+ with: { recipe: { columns: { id: true, title: true } } },
+ }),
+ db.query.cookingHistory.findMany({
+ where: eq(cookingHistory.userId, user.id),
+ orderBy: desc(cookingHistory.cookedAt),
+ limit: 20,
+ with: { recipe: { columns: { id: true, title: true } } },
+ }),
+ db
+ .select({ count: count() })
+ .from(ratings)
+ .where(and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey))),
+ db.query.ratings.findMany({
+ where: and(eq(ratings.userId, user.id), isNotNull(ratings.photoKey)),
+ orderBy: desc(ratings.createdAt),
+ limit: PAGE_SIZE,
+ offset: photoOffset,
+ with: { recipe: { columns: { id: true, title: true } } },
+ }),
+ ]);
+
+ const totalCooked = totalCookedRow[0]?.count ?? 0;
+ const streak = computeStreak(cookedDatesRows.map((r) => r.cookedAt));
+ const totalPhotos = totalPhotosRow[0]?.count ?? 0;
+
+ historyData = {
+ totalCooked,
+ streak,
+ lastCooked:
+ lastCookedRow && lastCookedRow.recipe
+ ? {
+ recipeId: lastCookedRow.recipe.id,
+ recipeTitle: lastCookedRow.recipe.title,
+ cookedAt: lastCookedRow.cookedAt.toISOString(),
+ }
+ : null,
+ recentEntries: recentEntries
+ .filter((e) => e.recipe)
+ .map((e) => ({
+ id: e.id,
+ recipeId: e.recipe!.id,
+ recipeTitle: e.recipe!.title,
+ cookedAt: e.cookedAt.toISOString(),
+ servings: e.servings,
+ })),
+ };
+
+ photosData = {
+ items: photoRows
+ .filter((r) => r.recipe && r.photoKey)
+ .map((r) => ({
+ id: r.id,
+ recipeId: r.recipe!.id,
+ recipeTitle: r.recipe!.title,
+ photoKey: r.photoKey!,
+ createdAt: r.createdAt.toISOString(),
+ })),
+ page: photoPage,
+ totalPages: Math.max(1, Math.ceil(totalPhotos / PAGE_SIZE)),
+ total: totalPhotos,
+ };
+ }
+
const initials = user.name
.split(" ")
.slice(0, 2)
@@ -103,6 +210,72 @@ export default async function UserProfilePage({ params, searchParams }: Params)
.join("")
.toUpperCase();
+ const recipesSection =
+ publicRecipes.length > 0 ? (
+
+
Recipes
+
+ {publicRecipes.map((recipe) => {
+ const cover = recipe.photos[0];
+ return (
+
+
+ {cover ? (
+
+ ) : (
+
+ 🍽️
+
+ )}
+
+
+
+ );
+ })}
+
+
+ {totalPages > 1 && (
+
+ {page > 1 && (
+ 1 ? `?page=${page - 1}` : ""}`}
+ className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
+ >
+ Previous
+
+ )}
+
+ Page {page} of {totalPages}
+
+ {page < totalPages && (
+
+ Next
+
+ )}
+
+ )}
+
+ ) : (
+
+
No public recipes yet.
+
+ );
+
return (
{/* Profile header */}
@@ -149,70 +322,17 @@ export default async function UserProfilePage({ params, searchParams }: Params)
- {/* Recipe grid */}
- {publicRecipes.length > 0 ? (
-
-
Recipes
-
- {publicRecipes.map((recipe) => {
- const cover = recipe.photos[0];
- return (
-
-
- {cover ? (
-
- ) : (
-
- 🍽️
-
- )}
-
-
-
- );
- })}
-
-
- {totalPages > 1 && (
-
- {page > 1 && (
- 1 ? `?page=${page - 1}` : ""}`}
- className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
- >
- Previous
-
- )}
-
- Page {page} of {totalPages}
-
- {page < totalPages && (
-
- Next
-
- )}
-
- )}
-
+ {/* Recipes / cooking history / photos */}
+ {isOwnProfile && historyData && photosData ? (
+
) : (
-
-
No public recipes yet.
-
+ recipesSection
)}
);
diff --git a/apps/web/app/api/internal/cron/weekly-digest/route.ts b/apps/web/app/api/internal/cron/weekly-digest/route.ts
new file mode 100644
index 0000000..bf55d6a
--- /dev/null
+++ b/apps/web/app/api/internal/cron/weekly-digest/route.ts
@@ -0,0 +1,131 @@
+import { NextRequest, NextResponse } from "next/server";
+import crypto from "node:crypto";
+import {
+ db,
+ users,
+ recipes,
+ comments,
+ ratings,
+ userFollows,
+ favorites,
+ eq,
+ and,
+ gte,
+ desc,
+ count,
+ sql,
+} from "@epicure/db";
+import { sendEmail, weeklyDigestHtml } from "@/lib/email";
+
+// Internal cron endpoint — triggered by the `digest-cron` container on a weekly
+// schedule (see docker/compose.prod.yml). Not part of the public API surface;
+// protected by a shared secret rather than user auth.
+//
+// Computes, for every user: new followers / new comments / new ratings on
+// their recipes in the last 7 days, plus a site-wide top-3 trending list, and
+// emails a summary. Sends to all users (all users have a non-null email) —
+// there's no per-user opt-out preference yet; out of scope for this pass.
+
+const CHUNK_SIZE = 20;
+
+function isAuthorized(req: NextRequest): boolean {
+ const secret = process.env["CRON_SECRET"];
+ if (!secret) return false;
+
+ const header = req.headers.get("authorization");
+ if (!header?.startsWith("Bearer ")) return false;
+ const provided = header.slice("Bearer ".length);
+
+ const a = Buffer.from(provided);
+ const b = Buffer.from(secret);
+ if (a.length !== b.length) return false;
+ return crypto.timingSafeEqual(a, b);
+}
+
+function chunk(arr: T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
+ return out;
+}
+
+export async function POST(req: NextRequest) {
+ if (!isAuthorized(req)) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+ const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
+
+ const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([
+ db.select({ id: users.id, email: users.email }).from(users),
+ db
+ .select({ userId: userFollows.followingId, n: count() })
+ .from(userFollows)
+ .where(gte(userFollows.createdAt, weekAgo))
+ .groupBy(userFollows.followingId),
+ db
+ .select({ userId: recipes.authorId, n: count() })
+ .from(comments)
+ .innerJoin(recipes, eq(comments.recipeId, recipes.id))
+ .where(gte(comments.createdAt, weekAgo))
+ .groupBy(recipes.authorId),
+ db
+ .select({ userId: recipes.authorId, n: count() })
+ .from(ratings)
+ .innerJoin(recipes, eq(ratings.recipeId, recipes.id))
+ .where(gte(ratings.createdAt, weekAgo))
+ .groupBy(recipes.authorId),
+ db
+ .select({
+ id: recipes.id,
+ title: recipes.title,
+ favoriteCount: sql`cast(count(${favorites.recipeId}) as int)`,
+ })
+ .from(recipes)
+ .leftJoin(
+ favorites,
+ and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, weekAgo))
+ )
+ .where(eq(recipes.visibility, "public"))
+ .groupBy(recipes.id)
+ .orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
+ .limit(3),
+ ]);
+
+ const followerMap = new Map(followerRows.map((r) => [r.userId, r.n]));
+ const commentMap = new Map(commentRows.map((r) => [r.userId, r.n]));
+ const ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n]));
+ const trendingList = trending.map((r) => ({ id: r.id, title: r.title }));
+
+ let sent = 0;
+ let failed = 0;
+
+ for (const batch of chunk(allUsers, CHUNK_SIZE)) {
+ const results = await Promise.allSettled(
+ batch.map((user) => {
+ const newFollowers = followerMap.get(user.id) ?? 0;
+ const newComments = commentMap.get(user.id) ?? 0;
+ const newRatings = ratingMap.get(user.id) ?? 0;
+
+ return sendEmail({
+ to: user.email,
+ subject: "Your weekly digest — Epicure",
+ html: weeklyDigestHtml({
+ newFollowers,
+ newComments,
+ newRatings,
+ trending: trendingList,
+ baseUrl,
+ }),
+ });
+ })
+ );
+
+ for (const r of results) {
+ if (r.status === "fulfilled") sent++;
+ else failed++;
+ }
+ }
+
+ return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed });
+}
diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts
index d968b3f..cf49ee1 100644
--- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts
+++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, eq, and } from "@epicure/db";
+import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
@@ -19,6 +19,7 @@ const Schema = z.object({
usePantry: z.boolean().default(false),
pantryMode: z.boolean().default(false),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
+ targetNutritionGoals: z.boolean().default(false),
});
export async function POST(req: NextRequest) {
@@ -56,6 +57,24 @@ export async function POST(req: NextRequest) {
pantryItemNames = pantry.map((p) => p.rawName);
}
+ // Optionally fetch the user's nutrition goals to nudge the AI toward them.
+ // Silently ignored (no-op) if the user hasn't set any goals — no need to
+ // fail the whole generation over a missing preference.
+ let nutritionGoals: { caloriesKcal?: number | null; proteinG?: number | null; carbsG?: number | null; fatG?: number | null } | undefined;
+ if (parsed.data.targetNutritionGoals) {
+ const goals = await db.query.userNutritionGoals.findFirst({
+ where: eq(userNutritionGoals.userId, userId),
+ });
+ if (goals && (goals.caloriesKcal || goals.proteinG || goals.carbsG || goals.fatG)) {
+ nutritionGoals = {
+ caloriesKcal: goals.caloriesKcal,
+ proteinG: goals.proteinG,
+ carbsG: goals.carbsG,
+ fatG: goals.fatG,
+ };
+ }
+ }
+
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
generateMealPlan(
{
@@ -65,6 +84,7 @@ export async function POST(req: NextRequest) {
days: parsed.data.days,
pantryMode: parsed.data.pantryMode,
difficulty: parsed.data.difficulty,
+ nutritionGoals,
},
{ ...config, userContext: privateBio ?? undefined },
locale
diff --git a/apps/web/app/api/v1/pantry/scan/barcode/route.ts b/apps/web/app/api/v1/pantry/scan/barcode/route.ts
new file mode 100644
index 0000000..e53df6d
--- /dev/null
+++ b/apps/web/app/api/v1/pantry/scan/barcode/route.ts
@@ -0,0 +1,81 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { requireSession } from "@/lib/api-auth";
+import { applyRateLimit } from "@/lib/rate-limit";
+
+const Schema = z.object({
+ barcode: z.string().trim().min(4).max(32).regex(/^[0-9]+$/, "Barcode must be numeric"),
+});
+
+type OffProduct = {
+ product_name?: string;
+ product_name_en?: string;
+ generic_name?: string;
+ quantity?: string;
+ product_quantity?: string;
+ product_quantity_unit?: string;
+ brands?: string;
+};
+
+type OffResponse = {
+ status: number;
+ product?: OffProduct;
+};
+
+/** Very rough unit guess from Open Food Facts' free-text `quantity` field (e.g. "500 g", "1 L"). */
+function extractUnit(quantity: string | undefined): string | undefined {
+ if (!quantity) return undefined;
+ const match = /([a-zA-Z]+)\s*$/.exec(quantity.trim());
+ return match?.[1]?.toLowerCase();
+}
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
+
+ const limited = await applyRateLimit(`rl:pantry-scan-barcode:${session!.user.id}`, 20, 60);
+ if (limited) return limited;
+
+ const { barcode } = parsed.data;
+
+ let offResponse: Response;
+ try {
+ offResponse = await fetch(
+ `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json`,
+ {
+ headers: { "User-Agent": "Epicure/1.0 (pantry-scan)" },
+ signal: AbortSignal.timeout(8000),
+ }
+ );
+ } catch {
+ return NextResponse.json({ error: "Barcode lookup service unavailable" }, { status: 502 });
+ }
+
+ if (!offResponse.ok) {
+ return NextResponse.json({ error: "Barcode lookup service unavailable" }, { status: 502 });
+ }
+
+ const data = await offResponse.json() as OffResponse;
+
+ if (data.status !== 1 || !data.product) {
+ return NextResponse.json({ found: false });
+ }
+
+ const product = data.product;
+ const rawName = product.product_name_en?.trim() || product.product_name?.trim() || product.generic_name?.trim();
+
+ if (!rawName) {
+ return NextResponse.json({ found: false });
+ }
+
+ return NextResponse.json({
+ found: true,
+ rawName: product.brands ? `${rawName} (${product.brands.split(",")[0]?.trim()})` : rawName,
+ quantity: product.product_quantity,
+ unit: extractUnit(product.product_quantity_unit) ?? extractUnit(product.quantity),
+ });
+}
diff --git a/apps/web/app/api/v1/pantry/scan/photo/route.ts b/apps/web/app/api/v1/pantry/scan/photo/route.ts
new file mode 100644
index 0000000..7542e15
--- /dev/null
+++ b/apps/web/app/api/v1/pantry/scan/photo/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { requireSession } from "@/lib/api-auth";
+import { applyRateLimit } from "@/lib/rate-limit";
+import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
+import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
+import { scanPantryPhoto } from "@/lib/ai/features/scan-pantry-photo";
+
+const Schema = z.object({
+ imageBase64: z.string().max(14_000_000),
+ mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]),
+});
+
+export async function POST(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const body = await req.json() as unknown;
+ const parsed = Schema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
+ }
+
+ const userId = session!.user.id;
+
+ const limited = await applyRateLimit(`rl:ai:${userId}`, 10, 60);
+ if (limited) return limited;
+
+ const configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
+ if (!configResult.ok) return configResult.response;
+ const aiConfig = configResult.data;
+
+ // Fall back to vision-capable defaults if no explicit model configured
+ if (!aiConfig.model) {
+ if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
+ else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
+ }
+
+ const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
+ scanPantryPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig)
+ );
+ if (!result.ok) return result.response;
+
+ return NextResponse.json(result.data);
+}
diff --git a/apps/web/app/api/v1/users/me/nutrition-diary/route.ts b/apps/web/app/api/v1/users/me/nutrition-diary/route.ts
new file mode 100644
index 0000000..8ad5441
--- /dev/null
+++ b/apps/web/app/api/v1/users/me/nutrition-diary/route.ts
@@ -0,0 +1,103 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db, cookingHistory, recipes, userNutritionGoals, eq, and, gte, lt } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+
+function isValidDate(value: string): boolean {
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(new Date(`${value}T00:00:00.000Z`).getTime());
+}
+
+export async function GET(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+ const userId = session!.user.id;
+
+ const dateParam = req.nextUrl.searchParams.get("date");
+ const date = dateParam && isValidDate(dateParam) ? dateParam : new Date().toISOString().slice(0, 10);
+
+ const dayStart = new Date(`${date}T00:00:00.000Z`);
+ const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
+
+ const rows = await db
+ .select({
+ id: cookingHistory.id,
+ recipeId: cookingHistory.recipeId,
+ servings: cookingHistory.servings,
+ cookedAt: cookingHistory.cookedAt,
+ recipeTitle: recipes.title,
+ baseServings: recipes.baseServings,
+ nutritionData: recipes.nutritionData,
+ })
+ .from(cookingHistory)
+ .leftJoin(recipes, eq(cookingHistory.recipeId, recipes.id))
+ .where(
+ and(
+ eq(cookingHistory.userId, userId),
+ gte(cookingHistory.cookedAt, dayStart),
+ lt(cookingHistory.cookedAt, dayEnd)
+ )
+ )
+ .orderBy(cookingHistory.cookedAt);
+
+ const totals = { calories: 0, proteinG: 0, carbsG: 0, fatG: 0, fiberG: 0, sodiumMg: 0 };
+ const entries: {
+ id: string;
+ recipeId: string;
+ title: string;
+ servings: number;
+ cookedAt: string;
+ nutritionKnown: boolean;
+ }[] = [];
+ let unknownCount = 0;
+
+ for (const row of rows) {
+ const servings = row.servings ?? row.baseServings ?? 1;
+ const perServing = row.nutritionData?.perServing;
+ const nutritionKnown = !!perServing;
+
+ if (perServing) {
+ totals.calories += perServing.calories * servings;
+ totals.proteinG += perServing.proteinG * servings;
+ totals.carbsG += perServing.carbsG * servings;
+ totals.fatG += perServing.fatG * servings;
+ totals.fiberG += perServing.fiberG * servings;
+ totals.sodiumMg += perServing.sodiumMg * servings;
+ } else {
+ unknownCount += 1;
+ }
+
+ entries.push({
+ id: row.id,
+ recipeId: row.recipeId,
+ title: row.recipeTitle ?? "Unknown recipe",
+ servings,
+ cookedAt: row.cookedAt.toISOString(),
+ nutritionKnown,
+ });
+ }
+
+ for (const key of Object.keys(totals) as (keyof typeof totals)[]) {
+ totals[key] = Math.round(totals[key]);
+ }
+
+ const goalsRow = await db.query.userNutritionGoals.findFirst({
+ where: eq(userNutritionGoals.userId, userId),
+ });
+
+ const goals = goalsRow
+ ? {
+ caloriesKcal: goalsRow.caloriesKcal,
+ proteinG: goalsRow.proteinG,
+ carbsG: goalsRow.carbsG,
+ fatG: goalsRow.fatG,
+ }
+ : null;
+
+ const coverage = {
+ calories: goals?.caloriesKcal ? Math.round((totals.calories / goals.caloriesKcal) * 100) : 0,
+ protein: goals?.proteinG ? Math.round((totals.proteinG / goals.proteinG) * 100) : 0,
+ carbs: goals?.carbsG ? Math.round((totals.carbsG / goals.carbsG) * 100) : 0,
+ fat: goals?.fatG ? Math.round((totals.fatG / goals.fatG) * 100) : 0,
+ };
+
+ return NextResponse.json({ date, totals, goals, coverage, entries, unknownCount });
+}
diff --git a/apps/web/app/print/[id]/page.tsx b/apps/web/app/print/[id]/page.tsx
index 990e545..79c2d1c 100644
--- a/apps/web/app/print/[id]/page.tsx
+++ b/apps/web/app/print/[id]/page.tsx
@@ -3,7 +3,7 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
-import { hasQuantity } from "@/lib/fractions";
+import { formatIngredientQuantity } from "@/lib/unit-conversion";
import { getMessages, formatMessage } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
@@ -14,6 +14,7 @@ export default async function RecipePrintPage({ params }: Params) {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
+ const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
@@ -103,7 +104,7 @@ export default async function RecipePrintPage({ params }: Params) {
{recipe.ingredients.map((ing) => (
- {[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")}
+ {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
{ing.rawName}
{ing.note && ({ing.note}) }
diff --git a/apps/web/app/print/collection/[id]/page.tsx b/apps/web/app/print/collection/[id]/page.tsx
index d437ae2..1bea56b 100644
--- a/apps/web/app/print/collection/[id]/page.tsx
+++ b/apps/web/app/print/collection/[id]/page.tsx
@@ -3,7 +3,7 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and, or } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
-import { hasQuantity } from "@/lib/fractions";
+import { formatIngredientQuantity } from "@/lib/unit-conversion";
import { getMessages, formatMessage } from "@/lib/i18n/server";
type Params = { params: Promise<{ id: string }> };
@@ -14,6 +14,7 @@ export default async function CollectionPrintPage({ params }: Params) {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
+ const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const col = await db.query.collections.findFirst({
where: and(
@@ -113,7 +114,7 @@ export default async function CollectionPrintPage({ params }: Params) {
{recipe.ingredients.map((ing) => (
- {[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")}
+ {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
{ing.rawName}
{ing.note && ({ing.note}) }
diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx
index 6c7ad9f..08b3a0a 100644
--- a/apps/web/app/r/[id]/page.tsx
+++ b/apps/web/app/r/[id]/page.tsx
@@ -40,6 +40,7 @@ export default async function PublicRecipePage({ params }: Params) {
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
+ const unitPref = (session?.user as { unitPref?: string } | undefined)?.unitPref === "imperial" ? "imperial" : "metric";
const recipe = await db.query.recipes.findFirst({
where: (r, { and, eq, ne }) => and(eq(r.id, id), ne(r.visibility, "private")),
@@ -137,6 +138,7 @@ export default async function PublicRecipePage({ params }: Params) {
{m.recipe.ingredients}
({
id: ing.id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order,
}))}
diff --git a/apps/web/components/cooking-mode/cooking-mode.tsx b/apps/web/components/cooking-mode/cooking-mode.tsx
index 74c5344..e671fa7 100644
--- a/apps/web/components/cooking-mode/cooking-mode.tsx
+++ b/apps/web/components/cooking-mode/cooking-mode.tsx
@@ -6,7 +6,7 @@ import { X, ChevronLeft, ChevronRight, List, Mic, MicOff, Play, Square, HelpCirc
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useLocale } from "@/lib/i18n/provider";
-import { hasQuantity } from "@/lib/fractions";
+import { formatIngredientQuantity, type UnitPref } from "@/lib/unit-conversion";
import { useWakeLock } from "@/lib/hooks/use-wake-lock";
type Step = { id: string; instruction: string; timerSeconds: number | null };
@@ -68,11 +68,13 @@ export function CookingMode({
recipeTitle,
steps,
ingredients,
+ unitPref = "metric",
}: {
recipeId: string;
recipeTitle: string;
steps: Step[];
ingredients: Ingredient[];
+ unitPref?: UnitPref;
}) {
const router = useRouter();
const t = useTranslations("cookingMode");
@@ -342,7 +344,7 @@ export function CookingMode({
{ingredients.map((ing, i) => (
- {hasQuantity(ing.quantity) ? ing.quantity : ""}{ing.unit ? ` ${ing.unit}` : ""}
+ {formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
{ing.rawName}
diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx
index 40da25b..c19ebed 100644
--- a/apps/web/components/layout/nav.tsx
+++ b/apps/web/components/layout/nav.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
-import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon } from "lucide-react";
+import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Apple } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import {
@@ -33,6 +33,7 @@ const NAV_ITEMS = [
{ href: "/feed", key: "feed", icon: Rss },
{ href: "/collections", key: "collections", icon: FolderOpen },
{ href: "/meal-plan", key: "mealPlan", icon: Calendar },
+ { href: "/nutrition", key: "nutrition", icon: Apple },
{ href: "/pantry", key: "pantry", icon: Package },
{ href: "/shopping-lists", key: "shopping", icon: ShoppingCart },
] as const;
diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx
index bd46c7b..ab3f54e 100644
--- a/apps/web/components/meal-plan/meal-planner.tsx
+++ b/apps/web/components/meal-plan/meal-planner.tsx
@@ -117,10 +117,12 @@ export function MealPlanner({
weekStart,
initialEntries,
userRecipes,
+ hasNutritionGoals = false,
}: {
weekStart: string;
initialEntries: Entry[];
userRecipes: UserRecipe[];
+ hasNutritionGoals?: boolean;
}) {
const [entries, setEntries] = useState(initialEntries);
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
@@ -132,6 +134,7 @@ export function MealPlanner({
const [usePantry, setUsePantry] = useState(true);
const [pantryMode, setPantryMode] = useState(false);
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
+ const [targetNutritionGoals, setTargetNutritionGoals] = useState(false);
const t = useTranslations("mealPlan");
const tRecipe = useTranslations("recipe");
@@ -158,6 +161,7 @@ export function MealPlanner({
usePantry,
pantryMode,
difficulty: aiDifficulty || undefined,
+ targetNutritionGoals: hasNutritionGoals && targetNutritionGoals,
}),
});
if (!res.ok) {
@@ -390,6 +394,21 @@ export function MealPlanner({
)}
+ {hasNutritionGoals && (
+
+ setTargetNutritionGoals(e.target.checked)}
+ disabled={aiGenerating}
+ />
+
+ {t("targetNutritionGoals")}
+ {t("targetNutritionGoalsDescription")}
+
+
+ )}
setShowAiModal(false)} disabled={aiGenerating}>
diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx
index 19abd98..4f150c8 100644
--- a/apps/web/components/meal-plan/pantry-manager.tsx
+++ b/apps/web/components/meal-plan/pantry-manager.tsx
@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
+import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Plus, Trash2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -33,6 +35,7 @@ function daysUntilExpiry(dateStr: string): number {
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
const t = useTranslations("pantry");
const tCommon = useTranslations("common");
+ const router = useRouter();
const [items, setItems] = useState(initialItems);
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
@@ -90,6 +93,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
{t("add")}
+ router.refresh()} />
{/* Item list */}
diff --git a/apps/web/components/nutrition/nutrition-diary.tsx b/apps/web/components/nutrition/nutrition-diary.tsx
new file mode 100644
index 0000000..a7f33c7
--- /dev/null
+++ b/apps/web/components/nutrition/nutrition-diary.tsx
@@ -0,0 +1,236 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { Card, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+type NutritionTotals = {
+ calories: number;
+ proteinG: number;
+ carbsG: number;
+ fatG: number;
+ fiberG: number;
+ sodiumMg: number;
+};
+
+type NutritionGoals = {
+ caloriesKcal: number | null;
+ proteinG: number | null;
+ carbsG: number | null;
+ fatG: number | null;
+} | null;
+
+type NutritionCoverage = {
+ calories: number;
+ protein: number;
+ carbs: number;
+ fat: number;
+};
+
+type DiaryEntry = {
+ id: string;
+ recipeId: string;
+ title: string;
+ servings: number;
+ cookedAt: string;
+ nutritionKnown: boolean;
+};
+
+type DiaryResponse = {
+ date: string;
+ totals: NutritionTotals;
+ goals: NutritionGoals;
+ coverage: NutritionCoverage;
+ entries: DiaryEntry[];
+ unknownCount: number;
+};
+
+function todayIso(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function NutritionDiary() {
+ const t = useTranslations("nutritionDiary");
+ const [date, setDate] = useState(todayIso());
+ // Tagged with the date it was fetched for, and set only from inside the
+ // fetch's own then/catch — never synchronously in the effect body — so we
+ // don't need a separate "loading" setState call at the top of the effect.
+ const [result, setResult] = useState<
+ { date: string; ok: true; data: DiaryResponse } | { date: string; ok: false } | null
+ >(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch(`/api/v1/users/me/nutrition-diary?date=${date}`)
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error("request failed"))))
+ .then((json: DiaryResponse) => {
+ if (!cancelled) setResult({ date, ok: true, data: json });
+ })
+ .catch(() => {
+ if (!cancelled) setResult({ date, ok: false });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [date]);
+
+ const loading = result === null || result.date !== date;
+ const error = !loading && result !== null && !result.ok;
+ const data = !loading && result !== null && result.ok ? result.data : null;
+
+ const bars = data
+ ? [
+ {
+ label: t("calories"),
+ value: data.totals.calories,
+ goal: data.goals?.caloriesKcal ?? null,
+ unit: "kcal",
+ percentage: data.coverage.calories,
+ colorClass: "bg-orange-500",
+ },
+ {
+ label: t("protein"),
+ value: data.totals.proteinG,
+ goal: data.goals?.proteinG ?? null,
+ unit: "g",
+ percentage: data.coverage.protein,
+ colorClass: "bg-blue-500",
+ },
+ {
+ label: t("carbs"),
+ value: data.totals.carbsG,
+ goal: data.goals?.carbsG ?? null,
+ unit: "g",
+ percentage: data.coverage.carbs,
+ colorClass: "bg-green-500",
+ },
+ {
+ label: t("fat"),
+ value: data.totals.fatG,
+ goal: data.goals?.fatG ?? null,
+ unit: "g",
+ percentage: data.coverage.fat,
+ colorClass: "bg-yellow-500",
+ },
+ ]
+ : [];
+
+ return (
+
+
+
+ {t("dateLabel")}
+ setDate(e.target.value || todayIso())}
+ className="w-auto"
+ />
+
+ {date !== todayIso() && (
+
setDate(todayIso())}
+ className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
+ >
+ {t("today")}
+
+ )}
+
+
+ {loading &&
{t("loading")}
}
+ {error &&
{t("loadError")}
}
+
+ {!loading && !error && data && (
+ <>
+ {!data.goals && (
+
+
+ {t("goalNotSet")}
+
+ {t("setGoalsCta")}
+
+
+
+ )}
+
+ {data.goals && (
+
+
+ {bars.map((bar) =>
+ bar.goal == null ? null : (
+
+
+ {bar.label}
+
+ {bar.value} / {bar.goal} {bar.unit}
+
+
+
+
+ )
+ )}
+
+
+ {t("fiber")}
+ {data.totals.fiberG}g
+
+
+ {t("sodium")}
+ {data.totals.sodiumMg}mg
+
+
+
+
+ )}
+
+
+
{t("entriesTitle")}
+ {data.entries.length === 0 ? (
+
{t("noEntries")}
+ ) : (
+
+ {data.entries.map((entry) => (
+
+
+ {entry.title}
+
+
+ {t("servingsLabel", { count: entry.servings })}
+ {!entry.nutritionKnown && (
+
+ {t("nutritionUnknownBadge")}
+
+ )}
+
+
+ ))}
+
+ )}
+ {data.unknownCount > 0 && (
+
{t("unknownNote", { count: data.unknownCount })}
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/components/pantry/barcode-scanner.tsx b/apps/web/components/pantry/barcode-scanner.tsx
new file mode 100644
index 0000000..1165e57
--- /dev/null
+++ b/apps/web/components/pantry/barcode-scanner.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Loader2 } from "lucide-react";
+
+export function BarcodeScanner({
+ onDetected,
+ onError,
+}: {
+ onDetected: (barcode: string) => void;
+ onError?: (message: string) => void;
+}) {
+ const t = useTranslations("pantry");
+ const videoRef = useRef(null);
+ const [starting, setStarting] = useState(true);
+ const detectedRef = useRef(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ let controls: { stop: () => void } | null = null;
+
+ async function start() {
+ try {
+ const { BrowserMultiFormatReader } = await import("@zxing/browser");
+ const reader = new BrowserMultiFormatReader();
+ if (cancelled || !videoRef.current) return;
+
+ controls = await reader.decodeFromVideoDevice(
+ undefined,
+ videoRef.current,
+ (result) => {
+ if (result && !detectedRef.current) {
+ detectedRef.current = true;
+ onDetected(result.getText());
+ controls?.stop();
+ }
+ }
+ );
+ if (!cancelled) setStarting(false);
+ } catch (err) {
+ if (!cancelled) {
+ onError?.(err instanceof Error ? err.message : t("cameraError"));
+ setStarting(false);
+ }
+ }
+ }
+
+ void start();
+
+ return () => {
+ cancelled = true;
+ controls?.stop();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+ {starting && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/pantry/pantry-scan-dialog.tsx b/apps/web/components/pantry/pantry-scan-dialog.tsx
new file mode 100644
index 0000000..fe7ea80
--- /dev/null
+++ b/apps/web/components/pantry/pantry-scan-dialog.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import { ScanBarcode, Camera, Loader2, X, ArrowLeft } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { BarcodeScanner } from "@/components/pantry/barcode-scanner";
+
+type Step = "choose" | "barcode-scan" | "barcode-confirm" | "photo-loading" | "photo-confirm";
+
+type DraftItem = { rawName: string; quantity: string; unit: string };
+
+function fileToBase64(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result as string;
+ const comma = result.indexOf(",");
+ resolve(comma !== -1 ? result.slice(comma + 1) : result);
+ };
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
+}
+
+export function PantryScanDialog({ onAdded }: { onAdded: () => void }) {
+ const t = useTranslations("pantry");
+ const [open, setOpen] = useState(false);
+ const [step, setStep] = useState("choose");
+ const [barcodeDraft, setBarcodeDraft] = useState(null);
+ const [photoDrafts, setPhotoDrafts] = useState([]);
+ const [busy, setBusy] = useState(false);
+ const fileRef = useRef(null);
+
+ function reset() {
+ setStep("choose");
+ setBarcodeDraft(null);
+ setPhotoDrafts([]);
+ }
+
+ function handleOpenChange(next: boolean) {
+ setOpen(next);
+ if (!next) reset();
+ }
+
+ async function handleBarcodeDetected(barcode: string) {
+ setBusy(true);
+ try {
+ const res = await fetch("/api/v1/pantry/scan/barcode", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ barcode }),
+ });
+ if (!res.ok) {
+ toast.error(t("scanLookupFailed"));
+ setStep("choose");
+ return;
+ }
+ const data = await res.json() as { found: boolean; rawName?: string; quantity?: string; unit?: string };
+ if (!data.found || !data.rawName) {
+ toast.error(t("scanNotFound"));
+ setStep("choose");
+ return;
+ }
+ setBarcodeDraft({ rawName: data.rawName, quantity: data.quantity ?? "", unit: data.unit ?? "" });
+ setStep("barcode-confirm");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function handlePhotoFile(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setStep("photo-loading");
+ try {
+ const imageBase64 = await fileToBase64(file);
+ const mimeType = (["image/jpeg", "image/png", "image/webp"].includes(file.type) ? file.type : "image/jpeg") as
+ | "image/jpeg"
+ | "image/png"
+ | "image/webp";
+
+ const res = await fetch("/api/v1/pantry/scan/photo", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ imageBase64, mimeType }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({})) as { error?: string };
+ toast.error(data.error ?? t("scanPhotoFailed"));
+ setStep("choose");
+ return;
+ }
+ const data = await res.json() as { items: { rawName: string; estimatedQuantity?: string; unit?: string }[] };
+ if (data.items.length === 0) {
+ toast.error(t("scanNoItemsFound"));
+ setStep("choose");
+ return;
+ }
+ setPhotoDrafts(
+ data.items.map((i) => ({ rawName: i.rawName, quantity: i.estimatedQuantity ?? "", unit: i.unit ?? "" }))
+ );
+ setStep("photo-confirm");
+ } finally {
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ }
+
+ async function confirmItems(items: DraftItem[]) {
+ setBusy(true);
+ try {
+ const res = await fetch("/api/v1/pantry/bulk", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ items: items
+ .filter((i) => i.rawName.trim())
+ .map((i) => ({
+ rawName: i.rawName.trim(),
+ quantity: i.quantity.trim() || undefined,
+ unit: i.unit.trim() || undefined,
+ })),
+ }),
+ });
+ if (!res.ok) {
+ toast.error(t("addFailed"));
+ return;
+ }
+ toast.success(t("scanAdded"));
+ onAdded();
+ handleOpenChange(false);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ <>
+ setOpen(true)}>
+ {t("scan")}
+
+
+
+
+
+
+ {step === "choose" && t("scanTitle")}
+ {step === "barcode-scan" && t("scanBarcodeTitle")}
+ {step === "barcode-confirm" && t("scanConfirmTitle")}
+ {step === "photo-loading" && t("scanAnalyzingTitle")}
+ {step === "photo-confirm" && t("scanConfirmTitle")}
+
+
+ {step === "choose" && t("scanDescription")}
+ {step === "barcode-scan" && t("scanBarcodeDescription")}
+ {(step === "barcode-confirm" || step === "photo-confirm") && t("scanConfirmDescription")}
+
+
+
+ {step === "choose" && (
+
+ setStep("barcode-scan")}
+ className="flex flex-col items-center gap-2 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
+ >
+
+ {t("scanBarcodeOption")}
+
+ fileRef.current?.click()}
+ className="flex flex-col items-center gap-2 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
+ >
+
+ {t("scanPhotoOption")}
+
+
+
+ )}
+
+ {step === "barcode-scan" && (
+
+
void handleBarcodeDetected(barcode)}
+ onError={() => { toast.error(t("cameraError")); setStep("choose"); }}
+ />
+ setStep("choose")} disabled={busy}>
+ {t("scanBack")}
+
+
+ )}
+
+ {step === "barcode-confirm" && barcodeDraft && (
+
+ )}
+
+ {step === "photo-loading" && (
+
+
+
{t("scanAnalyzingDescription")}
+
+ )}
+
+ {step === "photo-confirm" && (
+
+ )}
+
+ {(step === "barcode-confirm" || step === "photo-confirm") && (
+
+
+ {t("scanBack")}
+
+
+ void confirmItems(step === "barcode-confirm" && barcodeDraft ? [barcodeDraft] : photoDrafts)
+ }
+ disabled={busy}
+ >
+ {busy ? : t("scanAddToPantry")}
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/web/components/profile/profile-tabs.tsx b/apps/web/components/profile/profile-tabs.tsx
new file mode 100644
index 0000000..dfd09b7
--- /dev/null
+++ b/apps/web/components/profile/profile-tabs.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import Link from "next/link";
+import Image from "next/image";
+import { useTranslations, useLocale } from "next-intl";
+import { Flame, ChefHat, History } from "lucide-react";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { getPublicUrl } from "@/lib/storage";
+
+export type HistoryEntry = {
+ id: string;
+ recipeId: string;
+ recipeTitle: string;
+ cookedAt: string;
+ servings: number | null;
+};
+
+export type HistoryData = {
+ totalCooked: number;
+ streak: number;
+ lastCooked: { recipeId: string; recipeTitle: string; cookedAt: string } | null;
+ recentEntries: HistoryEntry[];
+};
+
+export type PhotoItem = {
+ id: string;
+ recipeId: string;
+ recipeTitle: string;
+ photoKey: string;
+ createdAt: string;
+};
+
+export type PhotosData = {
+ items: PhotoItem[];
+ page: number;
+ totalPages: number;
+ total: number;
+};
+
+function StatCard({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export function ProfileTabs({
+ username,
+ defaultTab,
+ recipesContent,
+ history,
+ photos,
+}: {
+ username: string;
+ defaultTab: string;
+ recipesContent: React.ReactNode;
+ history: HistoryData;
+ photos: PhotosData;
+}) {
+ const t = useTranslations("profilePage");
+ const locale = useLocale();
+
+ return (
+
+
+ {t("tabRecipes")}
+ {t("tabHistory")}
+ {t("tabPhotos")}
+
+
+ {recipesContent}
+
+
+
+
+
+
+ {history.totalCooked}
+
+ }
+ />
+
+
+ {t("streakDays", { count: history.streak })}
+
+ }
+ />
+
+ {history.lastCooked.recipeTitle}
+
+ ) : (
+ {t("noneYet")}
+ )
+ }
+ />
+
+
+ {history.recentEntries.length > 0 ? (
+
+
{t("recentActivity")}
+
+ {history.recentEntries.map((entry) => (
+
+
+ {entry.recipeTitle}
+
+ {new Date(entry.cookedAt).toLocaleDateString(locale)}
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {photos.items.length > 0 ? (
+
+
+ {photos.items.map((photo) => (
+
+
+
+
+
+
+ ))}
+
+
+ {photos.totalPages > 1 && (
+
+ {photos.page > 1 && (
+
+ {t("previous")}
+
+ )}
+
+ {t("pageOf", { page: photos.page, total: photos.totalPages })}
+
+ {photos.page < photos.totalPages && (
+
+ {t("next")}
+
+ )}
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/recipe/serving-scaler.tsx b/apps/web/components/recipe/serving-scaler.tsx
index eccef4a..2febc8a 100644
--- a/apps/web/components/recipe/serving-scaler.tsx
+++ b/apps/web/components/recipe/serving-scaler.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useTranslations } from "next-intl";
import { Minus, Plus, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { scaleQuantity, hasQuantity } from "@/lib/fractions";
+import { formatIngredientQuantity, type UnitPref } from "@/lib/unit-conversion";
import { SubstituteIngredientPopover } from "@/components/recipe/substitute-ingredient-popover";
type Ingredient = {
@@ -29,12 +29,14 @@ export function ServingScaler({
recipeTitle,
recipeId,
onAiScale,
+ unitPref = "metric",
}: {
baseServings: number;
ingredients: Ingredient[];
recipeTitle?: string;
recipeId?: string;
onAiScale?: (ingredients: ScaledIngredient[] | null) => void;
+ unitPref?: UnitPref;
}) {
const t = useTranslations("servingScaler");
const [servings, setServings] = useState(baseServings);
@@ -132,15 +134,15 @@ export function ServingScaler({
.sort((a, b) => a.order - b.order)
.map((ing) => {
const aiIng = aiScaledIngredients?.find((s) => s.rawName === ing.rawName);
- const scaled = scaleQuantity(ing.quantity, baseServings, servings);
return (
{aiIng
- ? `${hasQuantity(aiIng.quantity) ? aiIng.quantity : ""}${aiIng.unit ? ` ${aiIng.unit}` : ""}`
- : scaled
- ? `${scaled}${ing.unit ? ` ${ing.unit}` : ""}`
- : ing.unit ?? ""}
+ ? formatIngredientQuantity(aiIng.quantity, aiIng.unit, unitPref)
+ : formatIngredientQuantity(ing.quantity, ing.unit, unitPref, {
+ base: baseServings,
+ desired: servings,
+ })}
{ing.rawName}
diff --git a/apps/web/lib/ai/features/generate-meal-plan.ts b/apps/web/lib/ai/features/generate-meal-plan.ts
index 05657dc..1c2e5b4 100644
--- a/apps/web/lib/ai/features/generate-meal-plan.ts
+++ b/apps/web/lib/ai/features/generate-meal-plan.ts
@@ -37,6 +37,12 @@ export async function generateMealPlan(
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
pantryMode?: boolean;
difficulty?: "easy" | "medium" | "hard";
+ nutritionGoals?: {
+ caloriesKcal?: number | null;
+ proteinG?: number | null;
+ carbsG?: number | null;
+ fatG?: number | null;
+ };
},
config?: AiConfig & { userContext?: string },
locale?: string
@@ -59,6 +65,17 @@ export async function generateMealPlan(
? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.`
: "";
+ const goals = options.nutritionGoals;
+ const goalParts: string[] = [];
+ if (goals?.caloriesKcal) goalParts.push(`~${goals.caloriesKcal} kcal`);
+ if (goals?.proteinG) goalParts.push(`${goals.proteinG}g protein`);
+ if (goals?.carbsG) goalParts.push(`${goals.carbsG}g carbs`);
+ if (goals?.fatG) goalParts.push(`${goals.fatG}g fat`);
+ const nutritionClause =
+ goalParts.length > 0
+ ? `The user has a daily nutrition target of approximately ${goalParts.join(", ")} in total across breakfast, lunch, and dinner combined. Design each day's meals so that, together, they roughly add up to these daily targets — favor recipes and portions that plausibly fit (e.g. protein-forward mains if protein is high relative to calories, lighter sides if calories are constrained). This is a directional guide, not a strict constraint: never sacrifice food safety, coherence, or the dietary requirements above to hit a number exactly.`
+ : "";
+
const systemPrompt = pantryMode
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`;
@@ -76,6 +93,7 @@ export async function generateMealPlan(
dietaryClause,
difficultyClause,
pantryClause,
+ nutritionClause,
pantryMode
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
: "Ensure nutritional balance across the week. Vary ingredients and cooking styles.",
diff --git a/apps/web/lib/ai/features/scan-pantry-photo.ts b/apps/web/lib/ai/features/scan-pantry-photo.ts
new file mode 100644
index 0000000..f8188d9
--- /dev/null
+++ b/apps/web/lib/ai/features/scan-pantry-photo.ts
@@ -0,0 +1,43 @@
+import { generateObject } from "ai";
+import { z } from "zod";
+import { resolveModel, type AiConfig } from "../factory";
+
+const PantryScanSchema = z.object({
+ items: z.array(
+ z.object({
+ rawName: z.string(),
+ estimatedQuantity: z.string().optional(),
+ unit: z.string().optional(),
+ })
+ ).max(30),
+});
+
+export type PantryScanResult = z.infer;
+
+export async function scanPantryPhoto(
+ imageBase64: string,
+ mimeType: "image/jpeg" | "image/png" | "image/webp",
+ config?: AiConfig,
+): Promise {
+ const model = resolveModel(config);
+
+ const { object } = await generateObject({
+ model,
+ schema: PantryScanSchema,
+ system:
+ "You identify food items visible in a photo of a pantry, fridge, or shelf, or a single unbarcoded item (e.g. fresh produce). " +
+ "List each distinct item once with a short, common ingredient name. Only include an estimated quantity or unit when it can be " +
+ "reasonably judged from the image (e.g. '3' apples, '1' bunch). Do not guess brand names or nutrition facts.",
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "image", image: imageBase64, mediaType: mimeType },
+ { type: "text", text: "Identify the food items in this photo." },
+ ],
+ },
+ ],
+ });
+
+ return object;
+}
diff --git a/apps/web/lib/email.ts b/apps/web/lib/email.ts
index 0e91c96..f72b93c 100644
--- a/apps/web/lib/email.ts
+++ b/apps/web/lib/email.ts
@@ -104,6 +104,43 @@ export function notificationEmailHtml(title: string, body: string, url: string)
);
}
+export function weeklyDigestHtml(opts: {
+ newFollowers: number;
+ newComments: number;
+ newRatings: number;
+ trending: { id: string; title: string }[];
+ baseUrl: string;
+}) {
+ const { newFollowers, newComments, newRatings, trending, baseUrl } = opts;
+
+ const stats = [
+ newFollowers > 0 ? `${newFollowers} new follower${newFollowers === 1 ? "" : "s"}` : null,
+ newComments > 0 ? `${newComments} new comment${newComments === 1 ? "" : "s"} on your recipes` : null,
+ newRatings > 0 ? `${newRatings} new rating${newRatings === 1 ? "" : "s"} on your recipes` : null,
+ ].filter((s): s is string => s !== null);
+
+ const statsHtml = stats.length > 0
+ ? `
+ ${stats.map((s) => `${s} `).join("")}
+ `
+ : `Quiet week on your recipes — but here's what's trending.
`;
+
+ const trendingHtml = trending.length > 0
+ ? `Trending this week
+
+ ${trending.map((r) => `${r.title} `).join("")}
+ `
+ : "";
+
+ return layout(
+ "Your weekly digest — Epicure",
+ `Your week on Epicure
+ ${statsHtml}
+ ${trendingHtml}
+ ${btn(baseUrl, "Open Epicure")}`
+ );
+}
+
export function welcomeHtml(name: string) {
return layout(
"Welcome to Epicure",
diff --git a/apps/web/lib/unit-conversion.ts b/apps/web/lib/unit-conversion.ts
new file mode 100644
index 0000000..959b525
--- /dev/null
+++ b/apps/web/lib/unit-conversion.ts
@@ -0,0 +1,154 @@
+import { formatQuantity } from "./fractions";
+
+/**
+ * Display-time unit conversion for recipe ingredient quantities.
+ *
+ * Recipes are authored in whatever units the author used. This module
+ * converts weight/volume units to the viewing user's `unitPref` at render
+ * time only — stored recipe data is never rewritten. Units that aren't in
+ * the table below (counts, "to taste", "a pinch", "clove", cups/tsp/tbsp,
+ * etc.) are rendered unconverted, since they're either unit-less or
+ * genuinely ambiguous / shared across both measurement systems in cooking
+ * convention.
+ */
+
+export type UnitPref = "metric" | "imperial";
+
+type UnitKind = "weight" | "volume";
+
+type UnitEntry = {
+ system: UnitPref;
+ kind: UnitKind;
+ /** Conversion factor to the kind's base unit (grams for weight, ml for volume). */
+ factor: number;
+};
+
+// Only units we actually want to convert live here. Anything else (cup,
+// tbsp, tsp, "clove", "pinch", "to taste", ...) is intentionally omitted so
+// it renders unconverted.
+const UNIT_TABLE: Record = {
+ // metric weight
+ g: { system: "metric", kind: "weight", factor: 1 },
+ gram: { system: "metric", kind: "weight", factor: 1 },
+ grams: { system: "metric", kind: "weight", factor: 1 },
+ kg: { system: "metric", kind: "weight", factor: 1000 },
+ kilogram: { system: "metric", kind: "weight", factor: 1000 },
+ kilograms: { system: "metric", kind: "weight", factor: 1000 },
+
+ // imperial weight
+ oz: { system: "imperial", kind: "weight", factor: 28.3495 },
+ ounce: { system: "imperial", kind: "weight", factor: 28.3495 },
+ ounces: { system: "imperial", kind: "weight", factor: 28.3495 },
+ lb: { system: "imperial", kind: "weight", factor: 453.592 },
+ lbs: { system: "imperial", kind: "weight", factor: 453.592 },
+ pound: { system: "imperial", kind: "weight", factor: 453.592 },
+ pounds: { system: "imperial", kind: "weight", factor: 453.592 },
+
+ // metric volume
+ ml: { system: "metric", kind: "volume", factor: 1 },
+ milliliter: { system: "metric", kind: "volume", factor: 1 },
+ milliliters: { system: "metric", kind: "volume", factor: 1 },
+ millilitre: { system: "metric", kind: "volume", factor: 1 },
+ millilitres: { system: "metric", kind: "volume", factor: 1 },
+ l: { system: "metric", kind: "volume", factor: 1000 },
+ liter: { system: "metric", kind: "volume", factor: 1000 },
+ liters: { system: "metric", kind: "volume", factor: 1000 },
+ litre: { system: "metric", kind: "volume", factor: 1000 },
+ litres: { system: "metric", kind: "volume", factor: 1000 },
+
+ // imperial volume (deliberately excludes cup/tbsp/tsp — see note above)
+ "fl oz": { system: "imperial", kind: "volume", factor: 29.5735 },
+ "fl. oz": { system: "imperial", kind: "volume", factor: 29.5735 },
+ floz: { system: "imperial", kind: "volume", factor: 29.5735 },
+ "fluid ounce": { system: "imperial", kind: "volume", factor: 29.5735 },
+ "fluid ounces": { system: "imperial", kind: "volume", factor: 29.5735 },
+ qt: { system: "imperial", kind: "volume", factor: 946.353 },
+ quart: { system: "imperial", kind: "volume", factor: 946.353 },
+ quarts: { system: "imperial", kind: "volume", factor: 946.353 },
+ pt: { system: "imperial", kind: "volume", factor: 473.176 },
+ pint: { system: "imperial", kind: "volume", factor: 473.176 },
+ pints: { system: "imperial", kind: "volume", factor: 473.176 },
+ gal: { system: "imperial", kind: "volume", factor: 3785.41 },
+ gallon: { system: "imperial", kind: "volume", factor: 3785.41 },
+ gallons: { system: "imperial", kind: "volume", factor: 3785.41 },
+};
+
+function normalizeUnit(unit: string): string {
+ return unit.trim().toLowerCase().replace(/\.$/, "");
+}
+
+function roundTo(value: number, nearest: number): number {
+ return Math.round(value / nearest) * nearest;
+}
+
+/** Formats a metric quantity (g/kg/ml/l), trimming to at most 2 decimals. */
+function formatMetricNumber(value: number): string {
+ const rounded = Math.round(value * 100) / 100;
+ return Number.isInteger(rounded) ? String(rounded) : String(parseFloat(rounded.toFixed(2)));
+}
+
+/**
+ * Converts a numeric quantity + unit string to the target unit system,
+ * rounded to sensible cooking precision. Returns null when the unit isn't
+ * in the conversion table, or is already in the target system (nothing to
+ * convert).
+ */
+export function convertUnit(
+ value: number,
+ unit: string,
+ targetPref: UnitPref
+): { value: string; unit: string } | null {
+ if (!isFinite(value) || value <= 0) return null;
+ const entry = UNIT_TABLE[normalizeUnit(unit)];
+ if (!entry || entry.system === targetPref) return null;
+
+ const base = value * entry.factor;
+
+ if (entry.kind === "weight") {
+ if (targetPref === "metric") {
+ if (base >= 1000) return { value: formatMetricNumber(roundTo(base / 1000, 0.1)), unit: "kg" };
+ return { value: formatMetricNumber(roundTo(base, base < 20 ? 1 : 5)), unit: "g" };
+ }
+ const oz = base / 28.3495;
+ if (oz >= 16) return { value: formatQuantity(roundTo(oz / 16, 0.1)), unit: "lb" };
+ return { value: formatQuantity(roundTo(oz * 4, 1) / 4), unit: "oz" };
+ }
+
+ // volume
+ if (targetPref === "metric") {
+ if (base >= 1000) return { value: formatMetricNumber(roundTo(base / 1000, 0.1)), unit: "l" };
+ return { value: formatMetricNumber(roundTo(base, base < 50 ? 5 : 10)), unit: "ml" };
+ }
+ const flOz = base / 29.5735;
+ if (flOz >= 32) return { value: formatQuantity(roundTo(flOz / 32, 0.25)), unit: "qt" };
+ return { value: formatQuantity(roundTo(flOz * 2, 1) / 2), unit: "fl oz" };
+}
+
+/**
+ * Builds the full display string for an ingredient's quantity + unit,
+ * applying an optional serving-scale factor and, when the unit differs
+ * from the viewer's `unitPref`, appending an approximate converted value —
+ * e.g. "200 g (~7 oz)". The original authored value is always shown first
+ * so it's never lost or hidden.
+ */
+export function formatIngredientQuantity(
+ rawQuantity: string | null | undefined,
+ unit: string | null | undefined,
+ targetPref: UnitPref,
+ scale: { base: number; desired: number } = { base: 1, desired: 1 }
+): string {
+ if (!rawQuantity) return unit ?? "";
+ const parsed = parseFloat(rawQuantity);
+ if (isNaN(parsed) || parsed === 0) return unit ?? "";
+
+ const scaleFactor = scale.base === 0 ? 1 : scale.desired / scale.base;
+ const scaledValue = parsed * scaleFactor;
+ const originalText = `${formatQuantity(scaledValue)}${unit ? ` ${unit}` : ""}`;
+
+ if (!unit) return originalText;
+
+ const converted = convertUnit(scaledValue, unit, targetPref);
+ if (!converted) return originalText;
+
+ return `${originalText} (~${converted.value} ${converted.unit})`;
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index e7a9172..13d1c5b 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -7,6 +7,7 @@
"feed": "Feed",
"collections": "Collections",
"mealPlan": "Meal Plan",
+ "nutrition": "Nutrition",
"pantry": "Pantry",
"shopping": "Shopping",
"settings": "Settings",
@@ -303,7 +304,8 @@
},
"nutritionGoals": {
"title": "Daily Nutrition Goals",
- "description": "Set your daily targets. These are shown as progress bars on your meal plan."
+ "description": "Set your daily targets. These are shown as progress bars on your meal plan.",
+ "viewDiaryCta": "View nutrition diary"
},
"webhooksPage": {
"title": "Webhooks",
@@ -398,6 +400,27 @@
"fiber": "Fiber",
"sodium": "Sodium"
},
+ "nutritionDiary": {
+ "title": "Nutrition Diary",
+ "subtitle": "Your daily nutrition intake, computed from what you've cooked.",
+ "dateLabel": "Date",
+ "today": "Today",
+ "loading": "Loading…",
+ "loadError": "Couldn't load your nutrition diary. Please try again.",
+ "goalNotSet": "You haven't set daily nutrition goals yet.",
+ "setGoalsCta": "Set goals",
+ "calories": "Calories",
+ "protein": "Protein",
+ "carbs": "Carbs",
+ "fat": "Fat",
+ "fiber": "Fiber",
+ "sodium": "Sodium",
+ "entriesTitle": "Recipes cooked",
+ "noEntries": "Nothing logged for this day. Mark a recipe as \"cooked\" to see it here.",
+ "servingsLabel": "{count, plural, one {1 serving} other {{count} servings}}",
+ "nutritionUnknownBadge": "Nutrition unknown",
+ "unknownNote": "{count, plural, one {1 entry has no nutrition data and isn't included in the totals above.} other {{count} entries have no nutrition data and aren't included in the totals above.}}"
+ },
"explore": {
"title": "Explore",
"searchPlaceholder": "Search public recipes…",
@@ -430,12 +453,11 @@
"copyMarkdown": "Copy as Markdown",
"downloadMarkdown": "Download as Markdown",
"copiedToClipboard": "Copied to clipboard",
- "copyFailed": "Failed to copy",
+ "copyFailed": "Failed to copy to clipboard",
"print": "Print",
"share": "Share",
"deleteFailed": "Delete failed",
"updateFailed": "Update failed",
- "copyFailed": "Failed to copy to clipboard",
"deleted": "Deleted",
"cancel": "Cancel",
"delete": "Delete",
@@ -646,6 +668,8 @@
"includePantryItems": "Include pantry items",
"pantryMode": "Pantry mode — maximize use of what you have",
"pantryModeDescription": "Prefer meals that use multiple pantry ingredients and minimize extra shopping.",
+ "targetNutritionGoals": "Target my nutrition goals",
+ "targetNutritionGoalsDescription": "Nudge the AI to aim for your daily calorie and macro targets across the day's meals.",
"removeFailed": "Failed to remove",
"generationFailed": "Generation failed",
"anyDifficulty": "Any",
@@ -691,7 +715,26 @@
"colItem": "Item",
"colQuantity": "Quantity",
"colExpires": "Expires",
- "expiredDaysAgo": "Expired {days}d ago"
+ "expiredDaysAgo": "Expired {days}d ago",
+ "scan": "Scan",
+ "scanTitle": "Add by scan",
+ "scanDescription": "Scan a barcode or take a photo to quickly add pantry items.",
+ "scanBarcodeOption": "Scan barcode",
+ "scanPhotoOption": "Take/upload photo",
+ "scanBarcodeTitle": "Scan barcode",
+ "scanBarcodeDescription": "Point your camera at a product barcode.",
+ "scanConfirmTitle": "Confirm item",
+ "scanConfirmDescription": "Review and edit before adding to your pantry.",
+ "scanAnalyzingTitle": "Analyzing photo",
+ "scanAnalyzingDescription": "Identifying items in your photo…",
+ "scanBack": "Back",
+ "scanAddToPantry": "Add to pantry",
+ "scanAdded": "Added to pantry",
+ "scanLookupFailed": "Barcode lookup failed",
+ "scanNotFound": "Product not found. Try a photo scan instead.",
+ "scanPhotoFailed": "Photo scan failed",
+ "scanNoItemsFound": "No items recognized in this photo",
+ "cameraError": "Couldn't access the camera"
},
"feed": {
"title": "Feed",
@@ -984,5 +1027,21 @@
"privateBio": "AI context (private)",
"privateBioDescription": "Never shown publicly. Injected into AI prompts to personalise suggestions — add your dietary preferences, kitchen equipment, cooking skill level, allergies, etc.",
"privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…"
+ },
+ "profilePage": {
+ "tabRecipes": "Recipes",
+ "tabHistory": "Cooking History",
+ "tabPhotos": "Photos",
+ "statTotalCooked": "Total times cooked",
+ "statStreak": "Current streak",
+ "statLastCooked": "Last cooked",
+ "streakDays": "{count, plural, one {# day} other {# days}}",
+ "noneYet": "None yet",
+ "recentActivity": "Recent activity",
+ "noHistoryYet": "No cooking history yet.",
+ "noPhotosYet": "No photos yet.",
+ "previous": "Previous",
+ "next": "Next",
+ "pageOf": "Page {page} of {total}"
}
}
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 81a10d3..5cb41fe 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -7,6 +7,7 @@
"feed": "Fil d'actualité",
"collections": "Collections",
"mealPlan": "Planning repas",
+ "nutrition": "Nutrition",
"pantry": "Garde-manger",
"shopping": "Courses",
"settings": "Paramètres",
@@ -303,7 +304,8 @@
},
"nutritionGoals": {
"title": "Objectifs nutritionnels quotidiens",
- "description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas."
+ "description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas.",
+ "viewDiaryCta": "Voir le journal nutritionnel"
},
"webhooksPage": {
"title": "Webhooks",
@@ -398,6 +400,27 @@
"fiber": "Fibres",
"sodium": "Sodium"
},
+ "nutritionDiary": {
+ "title": "Journal nutritionnel",
+ "subtitle": "Votre apport nutritionnel quotidien, calculé à partir de ce que vous avez cuisiné.",
+ "dateLabel": "Date",
+ "today": "Aujourd'hui",
+ "loading": "Chargement…",
+ "loadError": "Impossible de charger votre journal nutritionnel. Veuillez réessayer.",
+ "goalNotSet": "Vous n'avez pas encore défini d'objectifs nutritionnels quotidiens.",
+ "setGoalsCta": "Définir les objectifs",
+ "calories": "Calories",
+ "protein": "Protéines",
+ "carbs": "Glucides",
+ "fat": "Lipides",
+ "fiber": "Fibres",
+ "sodium": "Sodium",
+ "entriesTitle": "Recettes cuisinées",
+ "noEntries": "Rien n'est enregistré pour ce jour. Marquez une recette comme « cuisinée » pour la voir ici.",
+ "servingsLabel": "{count, plural, one {1 portion} other {{count} portions}}",
+ "nutritionUnknownBadge": "Nutrition inconnue",
+ "unknownNote": "{count, plural, one {1 entrée n'a pas de données nutritionnelles et n'est pas incluse dans les totaux ci-dessus.} other {{count} entrées n'ont pas de données nutritionnelles et ne sont pas incluses dans les totaux ci-dessus.}}"
+ },
"explore": {
"title": "Explorer",
"searchPlaceholder": "Rechercher des recettes publiques…",
@@ -430,12 +453,11 @@
"copyMarkdown": "Copier en Markdown",
"downloadMarkdown": "Télécharger en Markdown",
"copiedToClipboard": "Copié dans le presse-papiers",
- "copyFailed": "Échec de la copie",
+ "copyFailed": "Échec de la copie dans le presse-papiers",
"print": "Imprimer",
"share": "Partager",
"deleteFailed": "Échec de la suppression",
"updateFailed": "Échec de la mise à jour",
- "copyFailed": "Échec de la copie dans le presse-papiers",
"deleted": "Supprimé",
"cancel": "Annuler",
"delete": "Supprimer",
@@ -634,6 +656,8 @@
"includePantryItems": "Inclure les articles du garde-manger",
"pantryMode": "Mode garde-manger — maximiser ce que vous avez",
"pantryModeDescription": "Préférer les repas utilisant plusieurs ingrédients du garde-manger et limiter les achats supplémentaires.",
+ "targetNutritionGoals": "Viser mes objectifs nutritionnels",
+ "targetNutritionGoalsDescription": "Inciter l'IA à viser vos objectifs quotidiens de calories et de macronutriments sur l'ensemble des repas de la journée.",
"removeFailed": "Échec de la suppression",
"generationFailed": "Échec de la génération",
"anyDifficulty": "Toute",
@@ -679,7 +703,26 @@
"colItem": "Article",
"colQuantity": "Quantité",
"colExpires": "Expire",
- "expiredDaysAgo": "Expiré il y a {days}j"
+ "expiredDaysAgo": "Expiré il y a {days}j",
+ "scan": "Scanner",
+ "scanTitle": "Ajouter par scan",
+ "scanDescription": "Scannez un code-barres ou prenez une photo pour ajouter rapidement des articles.",
+ "scanBarcodeOption": "Scanner un code-barres",
+ "scanPhotoOption": "Prendre/importer une photo",
+ "scanBarcodeTitle": "Scanner un code-barres",
+ "scanBarcodeDescription": "Pointez votre caméra vers un code-barres.",
+ "scanConfirmTitle": "Confirmer l'article",
+ "scanConfirmDescription": "Vérifiez et modifiez avant d'ajouter au garde-manger.",
+ "scanAnalyzingTitle": "Analyse de la photo",
+ "scanAnalyzingDescription": "Identification des articles dans votre photo…",
+ "scanBack": "Retour",
+ "scanAddToPantry": "Ajouter au garde-manger",
+ "scanAdded": "Ajouté au garde-manger",
+ "scanLookupFailed": "Échec de la recherche du code-barres",
+ "scanNotFound": "Produit introuvable. Essayez un scan photo.",
+ "scanPhotoFailed": "Échec du scan photo",
+ "scanNoItemsFound": "Aucun article reconnu sur cette photo",
+ "cameraError": "Impossible d'accéder à la caméra"
},
"feed": {
"title": "Fil d'actualité",
@@ -972,5 +1015,21 @@
"privateBio": "Contexte IA (privé)",
"privateBioDescription": "Jamais visible publiquement. Injecté dans les prompts IA pour personnaliser les suggestions — ajoutez vos préférences alimentaires, équipements, niveau de cuisine, allergies, etc.",
"privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…"
+ },
+ "profilePage": {
+ "tabRecipes": "Recettes",
+ "tabHistory": "Historique de cuisine",
+ "tabPhotos": "Photos",
+ "statTotalCooked": "Total de fois cuisinées",
+ "statStreak": "Série en cours",
+ "statLastCooked": "Dernière cuisson",
+ "streakDays": "{count, plural, one {# jour} other {# jours}}",
+ "noneYet": "Aucune pour le moment",
+ "recentActivity": "Activité récente",
+ "noHistoryYet": "Aucun historique de cuisine pour le moment.",
+ "noPhotosYet": "Aucune photo pour le moment.",
+ "previous": "Précédent",
+ "next": "Suivant",
+ "pageOf": "Page {page} sur {total}"
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 7d8c4e7..7835477 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"@base-ui/react": "^1.6.0",
"@epicure/db": "workspace:*",
"@hookform/resolvers": "^5.4.0",
+ "@zxing/browser": "^0.2.1",
"ai": "^6.0.209",
"better-auth": "^1.6.20",
"class-variance-authority": "^0.7.1",
diff --git a/docker/compose.prod.yml b/docker/compose.prod.yml
index d508e4b..27cfc28 100644
--- a/docker/compose.prod.yml
+++ b/docker/compose.prod.yml
@@ -65,6 +65,10 @@ services:
build:
context: ..
dockerfile: Dockerfile
+ # Explicit target: the Dockerfile's last stage is no longer `runner` now
+ # that `cron` was appended after it, and the default build target is
+ # whatever stage comes last — pin it so this doesn't silently change.
+ target: runner
args:
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY}
NEXT_PUBLIC_GITHUB_ENABLED: ${NEXT_PUBLIC_GITHUB_ENABLED}
@@ -102,6 +106,7 @@ services:
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
+ CRON_SECRET: ${CRON_SECRET}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL}
OPENAI_API_KEY: ${OPENAI_API_KEY}
@@ -118,6 +123,21 @@ services:
minio:
condition: service_healthy
+ # Fires the weekly digest email send by POSTing to `web`'s internal cron
+ # route on a schedule (see docker/cron/crontab). Tiny alpine+curl+crond
+ # image — not part of the app build, just a periodic HTTP trigger.
+ digest-cron:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ target: cron
+ restart: always
+ environment:
+ CRON_SECRET: ${CRON_SECRET}
+ WEB_INTERNAL_URL: http://web:3000
+ depends_on:
+ - web
+
volumes:
postgres_data:
redis_data:
diff --git a/docker/compose.yml b/docker/compose.yml
index 363933a..efd8e5f 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -1,3 +1,7 @@
+# Dev-only infra: postgres/redis/minio. The app and the `digest-cron` container
+# (docker/compose.prod.yml) aren't run here — use `pnpm dev` locally, and hit
+# POST /api/internal/cron/weekly-digest with the CRON_SECRET header directly if
+# you need to test the weekly digest send without waiting for the schedule.
services:
postgres:
image: postgres:17-alpine
diff --git a/docker/cron/crontab b/docker/cron/crontab
new file mode 100644
index 0000000..05c5a7d
--- /dev/null
+++ b/docker/cron/crontab
@@ -0,0 +1,2 @@
+# Weekly digest — every Monday at 08:00 UTC.
+0 8 * * 1 /usr/local/bin/run-digest.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
diff --git a/docker/cron/run-digest.sh b/docker/cron/run-digest.sh
new file mode 100644
index 0000000..6495ceb
--- /dev/null
+++ b/docker/cron/run-digest.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# Triggers the weekly digest email send on the `web` service. Run on a schedule
+# by busybox crond (see docker/cron/crontab). Fails loudly (non-zero exit,
+# stderr) but crond just tries again next week — no retry loop here on purpose,
+# keep this script trivial.
+set -eu
+
+: "${WEB_INTERNAL_URL:=http://web:3000}"
+
+if [ -z "${CRON_SECRET:-}" ]; then
+ echo "run-digest: CRON_SECRET is not set, refusing to call the digest endpoint" >&2
+ exit 1
+fi
+
+curl -fsS -X POST \
+ -H "Authorization: Bearer ${CRON_SECRET}" \
+ "${WEB_INTERNAL_URL}/api/internal/cron/weekly-digest"
diff --git a/packages/db/src/schema/social.ts b/packages/db/src/schema/social.ts
index 8846624..3166614 100644
--- a/packages/db/src/schema/social.ts
+++ b/packages/db/src/schema/social.ts
@@ -108,6 +108,11 @@ export const notifications = pgTable("notifications", {
index("notifications_user_unread_idx").on(t.userId, t.read),
]);
+export const cookingHistoryRelations = relations(cookingHistory, ({ one }) => ({
+ user: one(users, { fields: [cookingHistory.userId], references: [users.id] }),
+ recipe: one(recipes, { fields: [cookingHistory.recipeId], references: [recipes.id] }),
+}));
+
export const notificationsRelations = relations(notifications, ({ one }) => ({
actor: one(users, { fields: [notifications.actorId], references: [users.id] }),
recipe: one(recipes, { fields: [notifications.recipeId], references: [recipes.id] }),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index caa231c..fa6495a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.80.0(react@19.2.4))
+ '@zxing/browser':
+ specifier: ^0.2.1
+ version: 0.2.1(@zxing/library@0.23.0)
ai:
specifier: ^6.0.209
version: 6.0.209(zod@3.25.76)
@@ -2423,6 +2426,18 @@ packages:
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
+ '@zxing/browser@0.2.1':
+ resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==}
+ peerDependencies:
+ '@zxing/library': ^0.23.0
+
+ '@zxing/library@0.23.0':
+ resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
+ engines: {node: '>= 24.0.0'}
+
+ '@zxing/text-encoding@0.9.0':
+ resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -5041,6 +5056,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-custom-error@3.3.1:
+ resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
+ engines: {node: '>=14.0.0'}
+
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -5908,7 +5927,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
- '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
+ '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -5922,38 +5941,38 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
- '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
+ '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
drizzle-orm: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)
- '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
+ '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.2
- '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
+ '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
- '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
+ '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -7361,6 +7380,21 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
+ '@zxing/browser@0.2.1(@zxing/library@0.23.0)':
+ dependencies:
+ '@zxing/library': 0.23.0
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/library@0.23.0':
+ dependencies:
+ ts-custom-error: 3.3.1
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/text-encoding@0.9.0':
+ optional: true
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -7538,13 +7572,13 @@ snapshots:
better-auth@1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.9):
dependencies:
- '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
- '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
- '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
- '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
- '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
+ '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
+ '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
+ '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
+ '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
+ '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@noble/ciphers': 2.2.0
@@ -10323,6 +10357,8 @@ snapshots:
dependencies:
typescript: 5.9.3
+ ts-custom-error@3.3.1: {}
+
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0