feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
Six M-sized items from HANDOFF.md's new-features backlog: - Profile tabs: cooking-history stats (total cooked, last-cooked, streak) and a "cooked it" photo gallery, both owner-only - Display-time unit conversion (metric<->imperial) for recipe ingredients, respecting each user's unitPref; original value always shown alongside the conversion - Nutrition daily diary: per-day macro totals computed from cooking history x recipe nutritionData, compared against user goals - Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key) with an AI-vision fallback for unbarcoded items, always confirm-before- insert, both paths tier/rate-limited like other AI features - Weekly digest email: new followers/comments/ratings + trending recipes, sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron` compose service hitting a bearer-token-protected internal route - Meal-plan generation can now target a user's nutrition goals as a prompt-level nudge (recipes are AI-invented, not DB-sourced, so this can't be a hard macro constraint) Caught a real deploy-breaking issue while adding the cron stage: appending it after `runner` silently changed the Dockerfile's default build target, and `web`'s compose config didn't pin one — fixed by pinning `target: runner` explicitly. Verified with typecheck, lint, and three separate `docker build --target` runs (runner/cron/migrator) plus `docker compose config` validation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,4 +6,5 @@
|
||||
.env*
|
||||
!.env.example
|
||||
docker
|
||||
!docker/cron
|
||||
*.md
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -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"]
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<WeeklyNutritionBar weekStart={weekStart} />
|
||||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
|
||||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} hasNutritionGoals={hasNutritionGoals} />
|
||||
|
||||
{sharedMemberships.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{m.nutritionDiary.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
||||
</div>
|
||||
<NutritionDiary />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default async function PantryPage() {
|
||||
<div className="space-y-6">
|
||||
<PantryPageHeader items={mappedItems} />
|
||||
<ExpiringSoonSuggestions suggestions={suggestions} />
|
||||
<PantryManager initialItems={mappedItems} />
|
||||
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<CookingMode
|
||||
recipeId={id}
|
||||
recipeTitle={recipe.title}
|
||||
unitPref={unitPref}
|
||||
steps={recipe.steps.map((s) => ({
|
||||
id: s.id,
|
||||
instruction: s.instruction,
|
||||
|
||||
@@ -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) {
|
||||
<ServingScaler
|
||||
baseServings={recipe.baseServings}
|
||||
recipeTitle={recipe.title}
|
||||
unitPref={unitPref}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
id: ing.id,
|
||||
rawName: ing.rawName,
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-xl border p-6 space-y-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{m.nutritionDiary.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{m.nutritionDiary.subtitle}</p>
|
||||
</div>
|
||||
<Link href="/nutrition" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{m.settings.nutritionGoals.viewDiaryCta}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Recipes</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{publicRecipes.map((recipe) => {
|
||||
const cover = recipe.photos[0];
|
||||
return (
|
||||
<Link
|
||||
key={recipe.id}
|
||||
href={`/recipes/${recipe.id}`}
|
||||
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted overflow-hidden">
|
||||
{cover ? (
|
||||
<Image
|
||||
src={getPublicUrl(cover.storageKey)}
|
||||
alt={recipe.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
|
||||
🍽️
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{page > 1 && (
|
||||
<Link
|
||||
href={`/u/${username}${page - 1 > 1 ? `?page=${page - 1}` : ""}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{page < totalPages && (
|
||||
<Link
|
||||
href={`/u/${username}?page=${page + 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">No public recipes yet.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-10">
|
||||
{/* Profile header */}
|
||||
@@ -149,70 +322,17 @@ export default async function UserProfilePage({ params, searchParams }: Params)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipe grid */}
|
||||
{publicRecipes.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Recipes</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{publicRecipes.map((recipe) => {
|
||||
const cover = recipe.photos[0];
|
||||
return (
|
||||
<Link
|
||||
key={recipe.id}
|
||||
href={`/recipes/${recipe.id}`}
|
||||
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted overflow-hidden">
|
||||
{cover ? (
|
||||
<Image
|
||||
src={getPublicUrl(cover.storageKey)}
|
||||
alt={recipe.title}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
|
||||
🍽️
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{page > 1 && (
|
||||
<Link
|
||||
href={`/u/${username}${page - 1 > 1 ? `?page=${page - 1}` : ""}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
{page < totalPages && (
|
||||
<Link
|
||||
href={`/u/${username}?page=${page + 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Recipes / cooking history / photos */}
|
||||
{isOwnProfile && historyData && photosData ? (
|
||||
<ProfileTabs
|
||||
username={username}
|
||||
defaultTab={activeTab}
|
||||
recipesContent={recipesSection}
|
||||
history={historyData}
|
||||
photos={photosData}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">No public recipes yet.</p>
|
||||
</div>
|
||||
recipesSection
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<T>(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<number>`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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) => (
|
||||
<li key={ing.id}>
|
||||
<span className="qty">
|
||||
{[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")}
|
||||
{formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
|
||||
</span>
|
||||
{ing.rawName}
|
||||
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
|
||||
|
||||
@@ -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) => (
|
||||
<li key={ing.id}>
|
||||
<span className="qty">
|
||||
{[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")}
|
||||
{formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
|
||||
</span>
|
||||
{ing.rawName}
|
||||
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
|
||||
|
||||
@@ -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) {
|
||||
<h2 className="text-xl font-semibold">{m.recipe.ingredients}</h2>
|
||||
<ServingScaler
|
||||
baseServings={recipe.baseServings}
|
||||
unitPref={unitPref}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
id: ing.id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order,
|
||||
}))}
|
||||
|
||||
@@ -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) => (
|
||||
<li key={i} className="text-sm flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0 w-16 text-right">
|
||||
{hasQuantity(ing.quantity) ? ing.quantity : ""}{ing.unit ? ` ${ing.unit}` : ""}
|
||||
{formatIngredientQuantity(ing.quantity, ing.unit, unitPref)}
|
||||
</span>
|
||||
<span>{ing.rawName}</span>
|
||||
</li>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -117,10 +117,12 @@ export function MealPlanner({
|
||||
weekStart,
|
||||
initialEntries,
|
||||
userRecipes,
|
||||
hasNutritionGoals = false,
|
||||
}: {
|
||||
weekStart: string;
|
||||
initialEntries: Entry[];
|
||||
userRecipes: UserRecipe[];
|
||||
hasNutritionGoals?: boolean;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<Entry[]>(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({
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{hasNutritionGoals && (
|
||||
<label className="flex items-start gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 mt-0.5"
|
||||
checked={targetNutritionGoals}
|
||||
onChange={(e) => setTargetNutritionGoals(e.target.checked)}
|
||||
disabled={aiGenerating}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="text-sm font-medium block">{t("targetNutritionGoals")}</span>
|
||||
<span className="text-sm text-muted-foreground block">{t("targetNutritionGoalsDescription")}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<FakeProgressBar active={aiGenerating} durationMs={20000} label={aiPhase} />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" onClick={() => setShowAiModal(false)} disabled={aiGenerating}>
|
||||
|
||||
@@ -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<PantryItem[]>(initialItems);
|
||||
const [name, setName] = useState("");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
@@ -90,6 +93,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
<Button onClick={add} disabled={!name.trim() || adding} size="sm">
|
||||
<Plus className="h-4 w-4" /> {t("add")}
|
||||
</Button>
|
||||
<PantryScanDialog onAdded={() => router.refresh()} />
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="diary-date">{t("dateLabel")}</Label>
|
||||
<Input
|
||||
id="diary-date"
|
||||
type="date"
|
||||
value={date}
|
||||
max={todayIso()}
|
||||
onChange={(e) => setDate(e.target.value || todayIso())}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
{date !== todayIso() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDate(todayIso())}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||
>
|
||||
{t("today")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-muted-foreground">{t("loading")}</p>}
|
||||
{error && <p className="text-sm text-destructive">{t("loadError")}</p>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{!data.goals && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t("goalNotSet")}</p>
|
||||
<Link
|
||||
href="/settings/nutrition"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("setGoalsCta")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.goals && (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{bars.map((bar) =>
|
||||
bar.goal == null ? null : (
|
||||
<div key={bar.label} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{bar.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{bar.value} / {bar.goal} {bar.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded ${bar.colorClass}`}
|
||||
style={{ width: `${Math.min(bar.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-sm sm:grid-cols-2">
|
||||
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
|
||||
<span className="text-muted-foreground">{t("fiber")}</span>
|
||||
<span className="font-medium tabular-nums">{data.totals.fiberG}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between rounded-md bg-muted px-3 py-2">
|
||||
<span className="text-muted-foreground">{t("sodium")}</span>
|
||||
<span className="font-medium tabular-nums">{data.totals.sodiumMg}mg</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t("entriesTitle")}</h3>
|
||||
{data.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("noEntries")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{data.entries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<Link href={`/recipes/${entry.recipeId}`} className="hover:underline">
|
||||
{entry.title}
|
||||
</Link>
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span>{t("servingsLabel", { count: entry.servings })}</span>
|
||||
{!entry.nutritionKnown && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{t("nutritionUnknownBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.unknownCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t("unknownNote", { count: data.unknownCount })}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLVideoElement>(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 (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
||||
<video ref={videoRef} className="h-full w-full object-cover" muted playsInline />
|
||||
{starting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-8 top-1/2 h-16 -translate-y-1/2 rounded-md border-2 border-white/70" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<Step>("choose");
|
||||
const [barcodeDraft, setBarcodeDraft] = useState<DraftItem | null>(null);
|
||||
const [photoDrafts, setPhotoDrafts] = useState<DraftItem[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<ScanBarcode className="h-4 w-4" /> {t("scan")}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "choose" && t("scanTitle")}
|
||||
{step === "barcode-scan" && t("scanBarcodeTitle")}
|
||||
{step === "barcode-confirm" && t("scanConfirmTitle")}
|
||||
{step === "photo-loading" && t("scanAnalyzingTitle")}
|
||||
{step === "photo-confirm" && t("scanConfirmTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "choose" && t("scanDescription")}
|
||||
{step === "barcode-scan" && t("scanBarcodeDescription")}
|
||||
{(step === "barcode-confirm" || step === "photo-confirm") && t("scanConfirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "choose" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("barcode-scan")}
|
||||
className="flex flex-col items-center gap-2 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ScanBarcode className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{t("scanBarcodeOption")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex flex-col items-center gap-2 rounded-lg border p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{t("scanPhotoOption")}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handlePhotoFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "barcode-scan" && (
|
||||
<div className="space-y-3">
|
||||
<BarcodeScanner
|
||||
onDetected={(barcode) => void handleBarcodeDetected(barcode)}
|
||||
onError={() => { toast.error(t("cameraError")); setStep("choose"); }}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep("choose")} disabled={busy}>
|
||||
<ArrowLeft className="h-4 w-4" /> {t("scanBack")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "barcode-confirm" && barcodeDraft && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={barcodeDraft.rawName}
|
||||
onChange={(e) => setBarcodeDraft({ ...barcodeDraft, rawName: e.target.value })}
|
||||
placeholder={t("itemNamePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={barcodeDraft.quantity}
|
||||
onChange={(e) => setBarcodeDraft({ ...barcodeDraft, quantity: e.target.value })}
|
||||
placeholder={t("qtyPlaceholder")}
|
||||
className="w-20"
|
||||
/>
|
||||
<Input
|
||||
value={barcodeDraft.unit}
|
||||
onChange={(e) => setBarcodeDraft({ ...barcodeDraft, unit: e.target.value })}
|
||||
placeholder={t("unitPlaceholder")}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "photo-loading" && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">{t("scanAnalyzingDescription")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "photo-confirm" && (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{photoDrafts.map((draft, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draft.rawName}
|
||||
onChange={(e) =>
|
||||
setPhotoDrafts((prev) => prev.map((d, idx) => (idx === i ? { ...d, rawName: e.target.value } : d)))
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={draft.quantity}
|
||||
onChange={(e) =>
|
||||
setPhotoDrafts((prev) => prev.map((d, idx) => (idx === i ? { ...d, quantity: e.target.value } : d)))
|
||||
}
|
||||
placeholder={t("qtyPlaceholder")}
|
||||
className="w-16"
|
||||
/>
|
||||
<Input
|
||||
value={draft.unit}
|
||||
onChange={(e) =>
|
||||
setPhotoDrafts((prev) => prev.map((d, idx) => (idx === i ? { ...d, unit: e.target.value } : d)))
|
||||
}
|
||||
placeholder={t("unitPlaceholder")}
|
||||
className="w-16"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoDrafts((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step === "barcode-confirm" || step === "photo-confirm") && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset} disabled={busy}>
|
||||
{t("scanBack")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
void confirmItems(step === "barcode-confirm" && barcodeDraft ? [barcodeDraft] : photoDrafts)
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : t("scanAddToPantry")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tabs defaultValue={defaultTab} className="gap-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="recipes">{t("tabRecipes")}</TabsTrigger>
|
||||
<TabsTrigger value="history">{t("tabHistory")}</TabsTrigger>
|
||||
<TabsTrigger value="photos">{t("tabPhotos")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recipes">{recipesContent}</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label={t("statTotalCooked")}
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChefHat className="h-5 w-5 text-primary" />
|
||||
{history.totalCooked}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("statStreak")}
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
{t("streakDays", { count: history.streak })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("statLastCooked")}
|
||||
value={
|
||||
history.lastCooked ? (
|
||||
<Link href={`/recipes/${history.lastCooked.recipeId}`} className="text-base font-medium hover:underline line-clamp-1">
|
||||
{history.lastCooked.recipeTitle}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-base font-normal text-muted-foreground">{t("noneYet")}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{history.recentEntries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("recentActivity")}</h3>
|
||||
<div className="rounded-xl border divide-y overflow-hidden">
|
||||
{history.recentEntries.map((entry) => (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={`/recipes/${entry.recipeId}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||
>
|
||||
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 text-sm font-medium truncate">{entry.recipeTitle}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{new Date(entry.cookedAt).toLocaleDateString(locale)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">{t("noHistoryYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="photos">
|
||||
{photos.items.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.items.map((photo) => (
|
||||
<Link
|
||||
key={photo.id}
|
||||
href={`/recipes/${photo.recipeId}`}
|
||||
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted overflow-hidden">
|
||||
<Image
|
||||
src={getPublicUrl(photo.photoKey)}
|
||||
alt={photo.recipeTitle}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium leading-tight line-clamp-2">{photo.recipeTitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{photos.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{photos.page > 1 && (
|
||||
<Link
|
||||
href={`/u/${username}?tab=photos&ppage=${photos.page - 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
{t("previous")}
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
{t("pageOf", { page: photos.page, total: photos.totalPages })}
|
||||
</span>
|
||||
{photos.page < photos.totalPages && (
|
||||
<Link
|
||||
href={`/u/${username}?tab=photos&ppage=${photos.page + 1}`}
|
||||
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
{t("next")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="text-lg">{t("noPhotosYet")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<li key={ing.id} className="flex gap-2 text-sm group">
|
||||
<span className="font-medium tabular-nums min-w-[3rem] text-right">
|
||||
{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,
|
||||
})}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{ing.rawName}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<typeof PantryScanSchema>;
|
||||
|
||||
export async function scanPantryPhoto(
|
||||
imageBase64: string,
|
||||
mimeType: "image/jpeg" | "image/png" | "image/webp",
|
||||
config?: AiConfig,
|
||||
): Promise<PantryScanResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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 ? `<strong>${newFollowers}</strong> new follower${newFollowers === 1 ? "" : "s"}` : null,
|
||||
newComments > 0 ? `<strong>${newComments}</strong> new comment${newComments === 1 ? "" : "s"} on your recipes` : null,
|
||||
newRatings > 0 ? `<strong>${newRatings}</strong> new rating${newRatings === 1 ? "" : "s"} on your recipes` : null,
|
||||
].filter((s): s is string => s !== null);
|
||||
|
||||
const statsHtml = stats.length > 0
|
||||
? `<ul style="margin:0 0 24px;padding:0 0 0 20px;color:#57534e;line-height:1.8;">
|
||||
${stats.map((s) => `<li>${s}</li>`).join("")}
|
||||
</ul>`
|
||||
: `<p style="margin:0 0 24px;color:#57534e;line-height:1.6;">Quiet week on your recipes — but here's what's trending.</p>`;
|
||||
|
||||
const trendingHtml = trending.length > 0
|
||||
? `<h2 style="margin:0 0 8px;font-size:16px;">Trending this week</h2>
|
||||
<ol style="margin:0 0 8px;padding:0 0 0 20px;color:#57534e;line-height:1.8;">
|
||||
${trending.map((r) => `<li><a href="${baseUrl}/recipes/${r.id}" style="color:#1c1917;">${r.title}</a></li>`).join("")}
|
||||
</ol>`
|
||||
: "";
|
||||
|
||||
return layout(
|
||||
"Your weekly digest — Epicure",
|
||||
`<h1 style="margin:0 0 8px;font-size:24px;">Your week on Epicure</h1>
|
||||
${statsHtml}
|
||||
${trendingHtml}
|
||||
${btn(baseUrl, "Open Epicure")}`
|
||||
);
|
||||
}
|
||||
|
||||
export function welcomeHtml(name: string) {
|
||||
return layout(
|
||||
"Welcome to Epicure",
|
||||
|
||||
@@ -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<string, UnitEntry> = {
|
||||
// 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})`;
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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] }),
|
||||
|
||||
Generated
+56
-20
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user