Which unit a person uses is regional and personal, not part of the measurement: 82 kg and 180.8 lb are one weight, and blood glucose is mmol/L in one country and mg/dL in the next. So Datler stores every measured value in one base unit per dimension and treats the unit as a thin layer at the two edges: converted from what was typed when a value is captured, and to what the reader prefers when it is shown. Everything between, aggregation, charts, derived formulas, zones and analysis, reads the stored base number and knows nothing about units, and two datasets are comparable with no conversion when they are read. Storing the unit a person typed instead would put a conversion into every read, aggregate and comparison, and make a change of preference a rewrite of history.

The registry

packages/core/src/units.ts is the one source of conversion factors. Each dimension has a base (what is stored), a conventional display unit (the fallback for showing it), and its units, each { code, label, toBase, offset? } with base = value × toBase + offset.

dimensionbase (stored)shown by defaultunits
massgkgmg, g, kg, oz, lb, st
volumemlmlml, L, tsp, tbsp, fl oz, cup, pt (US)
distancemkmmm, cm, m, km, in, ft, yd, mi
energykcalkcalkcal, cal, kJ, J
durationsminms, s, min, h, d
temperature°C°C°C, °F, K (affine, through offset)
glucosemmol/Lmmol/Lmmol/L, mg/dL
speedm/skm/hm/s, km/h, mph, kn
pressurekPahPakPa, hPa, inHg, mmHg

Physical factors are universal constants, so the registry is reference data rather than domain knowledge, and it is code rather than a table: there are no rows to migrate. A number can also carry units of its own beside its dimension’s (extra_units): Daily Alcohol is counted in standard drinks of 13.45 g, which would be no unit for flour. The registry’s conversion helpers take them as an extra argument. Only a module sets a number’s own units; the trackable form keeps them but has no place to add one.

Conversions with no constant factor are not units and are not in the registry: pace against speed, fuel consumption against economy, and currency, whose rate changes over time. Keeping them out keeps the registry a pure factor-and-offset table and the base-storage rule without exception.

Which unit a value is shown and typed in

A number declares its dimension, which picks the base and the legal units, and may pin a unit. The unit shown and typed resolves top down, first hit wins (displayUnitCode in apps/renderer/src/lib/units.ts):

  1. the unit the entry was recorded in, where one is passed;
  2. the trackable’s own unit;
  3. the person’s preference for the dimension, set in Settings › Units and kept in one setting, units:preferences ({ mass: "lb", … });
  4. the dimension’s conventional display unit (kg for mass, though it stores grams).

A trackable that pins its unit therefore shows that unit whatever the preference says. The field form offers “my preference” as the default unit and lists the dimension’s units beside it, and the Zones section says which unit its bounds are typed in. A number with no dimension keeps a free-text unit label and is never converted.

Capture converts at one choke point: an entry’s value_number is converted from the display unit to base as it is written (toStore in useEntries), and a record’s measured number fields are converted by their own field input. Display converts back through toDisplay and valueLabel wherever a value is read out: cards, the trackable’s page, charts’ axes and tooltips, the table, the calendar and analysis. A scalar entry keeps no unit of its own: its dimension is fixed, so its display unit is re-resolved on every read and nothing is lost, and nothing passes an entry unit for a scalar. Converted values are tidied to twelve significant digits, so 78.1 kg does not come back as 78.10000000000001.

Durations are seconds, everywhere

Duration’s base is seconds with no exception: a number with dimension: "duration" stores seconds, and so does an interval’s length (end − start). storedDimension(schema) is the one answer to “are these the same quantity?” and reports duration for an interval by construction, with no field to forget to set, so derived arithmetic over a sleep and a meditation adds like with like. Storage and display stay separate questions: an interval renders through the duration formatter (“7h 45m”), not through a display unit, and does not take part in the preference chain.

Bounds keep the unit they were typed in

Bounds are intentions rather than measurements, so they are not converted when stored: a zone’s bounds and a number’s limits (limits_unit) are kept in the unit they were typed in and converted wherever they are compared or shown; the Zones note says how. An achievement’s running-total step is typed in the display unit and stored converted to base (Achievements).

Catalog items have their own units

A unit like “1 cookie = 17 g” belongs to one item and cannot live in a global table, so Catalog Entities are the second place units live. An item’s values are stored per one basis unit, its catalog’s base_unit (a gram for Food) unless the item overrides it, and the item carries named units (units[], each { code, label, per_basis }): with a gram basis, per_basis is the unit’s weight in grams, shown in the picker beside its name. The picker offers grams and the food’s named units; an item with no weight (a coffee, a home-cooked meal) has a basis of its own, such as a serving, and offers only that.

An amount logged against an item stores { quantity, unit, base }, where base is the amount in basis units worked out and frozen when it is logged (basisAmountOf). Every total reads base; quantity and unit only label the entry (”× 1 cookie”). Redefining a cookie as 18 g later therefore cannot change what a past entry meant. An entry written without a base falls back to quantity × unit. So 1 egg and 50 g of the same egg resolve to the same nutrition, which lets a food be logged when nobody weighed it, 1 egg for an estimate and grams when measured, rather than not logged at all; and the per-100 g and per-serving views of a catalog scale the stored per-gram values for display only. A field can also carry a density to be typed as a share of a volume: alcohol at 0.789 g/ml, typed as % ABV. See Diet and Nutrition.

Code

  • packages/core/src/units.ts: the registry, toBase, fromBase, convert, hasUnit, unitsFor.
  • packages/core/src/value-schema.ts: NumberValueSchema (dimension, unit, limits_unit, extra_units), storedDimension, scaleOf, zoneInBase, numberLimits.
  • apps/renderer/src/lib/units.ts: displayUnitCode, toDisplay, toStore, displayZones; apps/renderer/src/hooks/useUnitPrefs.ts.
  • packages/core/src/entity-units.ts: an item’s basis, named units, basisAmountOf, and the display-basis scaling.

Value Types · Zones · Catalog Entities · Diet and Nutrition · Capture Model · Presentation Layer