@mealos/units
v0.1.0
Published
Pure unit normalization for food quantities and unit prices. No dependencies.
Readme
@mealos/units
S2 unit normalization (REQUIREMENTS.md §5 S2). Pure TypeScript, no dependencies, no LLM.
normalize(qty, ingredient, { volumeSystem })converts a recipe or package quantity to the ingredient's canonical unit (g|ml|count).convert(qty, toUnit, opts)converts within one dimension (g↔kg↔oz↔lb, ml↔l↔tsp↔tbsp↔cup).sum(results, canonicalUnit)aggregates. If any input is unknown, the total is unknown, never a partial sum.pricePerCanonicalUnit(price, packageSizeAsSold, ingredient, opts)returns the exact ratio price ÷ normalized size. It never divides Money; rounding belongs topackages/economics.compareUnitPrice(a, b)orders two unit prices by cross-multiplying, without dividing.KNOWN_UNITSandisKnownUnit(s)expose the accepted vocabulary for a parser's alias layer.
When no conversion path exists, every function returns { ok: false, reason, detail }, never a guess. reason
says what to ask for: unknown-unit, not-in-volume-system, missing-density, missing-count-weight,
count-label-mismatch, incompatible-units or invalid-amount.
Provenance never drops
Every result carries exact and, when an estimated factor was used, confidence (invariant 3).
- Standard conversions within mass or volume are definitions: they add no inexactness, and they keep whatever
exact/confidencethe input already had. Re-converting an "≈" value stays "≈". - Every path through
countWeightordensityisexact: false, including count → count ("2 limes"), and carries the lowestconfidenceof the factors used. Noconfidencemeans every factor was curated or defined. sumANDsexactand takes the lowestconfidence. So doescompareUnitPrice, whose verdict is only as exact as both prices.- The Convex validators (
vNormalizedQuantity,vUnitPrice) store both fields.apps/web/test/units-parity.tsfails typecheck if they drift from these types.
Volume systems
volumeSystem has no default: a cup is 236.59 ml (US) or 250 ml (metric), and assuming one would be a guess.
US customary units (fl oz, pint, quart, gallon) exist only under "us". Under "metric" they return
not-in-volume-system rather than silently using US sizes (a UK pint is 568 ml).
Counts
A count label (clove) matches the ingredient's countWeight.label exactly or with +s / +es. A generic count
word ('', each, whole, pcs, cái, …) counts the countWeight unit only when that label is the ingredient's
own canonicalName or pluralName. "3 eggs" works (label egg); "1 whole" garlic, whose label is clove, returns
count-label-mismatch instead of pricing a head as one clove.
Strict on purpose
Unit strings must be spelled as listed in KNOWN_UNITS: English units and aliases, and Vietnamese units with
diacritics (muỗng canh, muỗng cà phê, lạng, ký, …). Input and stored labels are NFC-normalized, lowercased
and whitespace-collapsed; that is encoding, not tolerance.
Free-text tolerance does not belong here. Typos, unaccented Vietnamese (muong canh), irregular plurals
(leaves → leaf), and phrases like "a pinch" are the job of the Phase 2 ingredient-line parser's alias pass, which
maps what people write onto the strings in KNOWN_UNITS. Keeping this layer strict means every number it produces
traces to a defined conversion, and anything else surfaces as "needs unit info".
Open schema questions for Phase 2
recipeIngredients.normalizedQtyis a stored copy ofnormalize()output. It goes stale when an ingredient'sdensityorcountWeightis edited (S2 FR4 makes them editable). Phase 2 should either compute it on read or treat it as a cache keyed to the ingredient'supdatedAt.- No table stores
volumeSystem(e.g. onrecipesor at product intake), so callers would have to hardcode one.
