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:
Arnaud
2026-07-10 08:06:28 +02:00
parent 913410dbf3
commit b0849c3989
40 changed files with 1964 additions and 112 deletions
@@ -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;
}
+37
View File
@@ -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",
+154
View File
@@ -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})`;
}