A zone is a named, coloured range over a trackable’s values: “Healthy” from 78 to 81 kg, “Daily goal” from 8 glasses up, “Limit” under 2,000 kcal. A trackable can have any number of them. A target is not a separate thing: it is a zone with one open end, and the shape of its bounds says what kind it is. A target type would carry nothing a zone does not, and colouring a chart with several zones never costs a trackable its readout.

boundsshapereads as
min onlya floor to get above (goal)how far below it, and the fraction of the way there
max onlya ceiling to stay under (limit)the fraction used while inside it
min and maxa range to sit insidein or out, and the distance to the nearest edge

Zones are inert

A zone is a labelled region and nothing else. It carries no goal flag and no direction: whether a zone is good, and to whom, is said by whatever names it. That is what lets one zone colour a chart, qualify an achievement and feed a derived trackable at once without any of them owning it. Direction is implied by which bound exists, never stored, so it cannot disagree with the bounds. Nor does a zone have a period: it applies to its trackable’s value, so a daily limit is a zone on a trackable whose value is a day’s (Daily Calories), and the period lives in that trackable’s definition.

Because zones are referenced, they are rows in the zone table (trackable id, label, colour, min_value, max_value, unit, sort order), cascading with their trackable, and an achievement’s zone_id cascades with the zone: a rule about a range that no longer exists has nothing to say. They reach the renderer as Trackable.zones, loaded with the trackables in one query. Saving a trackable’s zones keeps their ids: a zone that comes back with its id is updated in place, one without is inserted, and only a zone left out is deleted (with the rules that name it). A save that leaves zones out leaves them alone, so a rename cannot wipe them; an empty list deletes them all.

One rule for containment

A value is inside a zone when min <= value < max: the lower bound inclusive, the upper exclusive (zoneContains). That is the only rule under which zones tile — Low [0,4), Healthy [4,7), High [7,∞) covers the axis once and 7 is unambiguously High — and every reader uses it: the chart, the calendar, the readout, the zone node and achievements. A zone with neither bound contains everything. The cost is that a ceiling written as max: 2000 does not hold a day of exactly 2000, and the Zones section says “min up to but not including max”.

The readout

zoneReading(zones, value) reads a value against a trackable’s zones by geometry alone, so one zone and five read the same way:

  • in — the first zone, in the author’s order, that contains the value;
  • toward — the nearest zone the value is not in, with the bound to cross and the distance to it, which is what makes “1.5 kg from Healthy” sayable while standing in Overweight;
  • progress — a fraction, only where one means something: inside a ceiling (1,500 of a 2,000 cap is 75% of something being consumed), or outside a zone with a single bound (5 of 8 glasses). Not inside a floor already cleared (80 kg is not 104% of 77 kg), not for a range, not against a bound of zero, and never on an interval scale — a clock time, or Celsius — whose zero is a convention (scaleOf), where a distance still reads and a percentage does not.

The trackable’s page shows the full readout and a Home card a one-line bar, both from the same reading, so they cannot disagree about where a value stands. Inside a zone the readout says “from” its neighbour rather than “to”, since which neighbour is worth reaching is a judgement a zone does not carry, and it drops a distance that formats as zero (a value exactly on a shared bound). A chart draws zones as bands behind its marks and a calendar colours days by them (Chart, Calendar Drill-down).

Bounds and units

A logged value is stored in its dimension’s base unit; a zone’s bounds are kept in the unit they were typed in (unit on the row), not normalised to base. A bound is an intention — “200 lb” — and the unit chosen is part of what was said, where a logged measurement’s unit is incidental. So every reader converts:

  • for display, displayZones turns bounds into the trackable’s current display unit, so a preference change from kg to lb moves the readout with the readings rather than silently re-denominating the target;
  • for evaluation, zoneInBase turns them into base units before comparing against a stored series, or a kg zone against a gram series would match no day at all. It looks the unit up in the registry alone, not among the number’s own extra units, so a bound typed in one of those (Daily Alcohol’s drink) reaches the zone node and achievements unconverted, while displayZones converts it.

A bound with no recorded unit is read as display-space, and the form records the unit the next time it is saved. The Zones section names the unit its bounds are in and where it came from (your preference for the dimension, the trackable’s own unit, or the unit of what a derived trackable aggregates). Bounds are typed the way the trackable’s values read: an amount for a number, a duration for an interval (“7h 30m”, “45m”, “7:30”; a bare number is refused because 8 is hours for a sleep and minutes for a focus timer), and a clock time for a time of day, placed in the reading’s frame (noon to noon for a bedtime, so 23:00 is the evening before). Durations and clock times are stored in seconds with no unit, since no preference can rescale them. See Units & Dimensions.

Where zones can be set

The trackable form’s Zones section, for a number, an interval, a trackable logged with no value (read by the time of day it was logged), and a derived trackable whose formula yields a number or a clock time. Text and choices have no magnitude to bound. A zone given neither bound is dropped on save, since it would contain everything and distinguish nothing. A chart’s zone band opens the form at Zones from its popover.

Code

  • packages/core/src/value-schema.ts — Zone, zoneContains, zoneInBase, scaleOf.
  • packages/core/src/target.ts — zoneKind, zoneReading.
  • packages/core/src/zone-repository.ts — id-preserving set.
  • apps/renderer/src/lib/units.ts — displayZones.
  • apps/renderer/src/components/trackables/TargetReadout.tsx, and the Zones section of apps/renderer/src/components/trackables/TrackableForm.tsx.

Achievements · Derived Trackables · Presentation Layer · Chart · Calendar Drill-down · Units & Dimensions · Database Schema