b0849c3989
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>
155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
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})`;
|
|
}
|