Files
Epicure/apps/web/lib/unit-conversion.ts
Arnaud b0849c3989 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>
2026-07-10 08:06:28 +02:00

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})`;
}