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:
@@ -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})`;
|
||||
}
|
||||
Reference in New Issue
Block a user