A Tag is how trackables are organised. A Trackable can have any number of tags, and a tag can sit under any number of other tags, as deep as you like; only a cycle is refused. A tag counts as every tag above it, worked out on read: Run tagged Cardio, with Cardio under Fitness, is in Fitness too. A tag holds no values and takes no entries: something logged about things done together, such as how long a workout lasted, is an entry of a Routine, and a number about everything a tag holds is its summary, a derived trackable inside it (below).
There is no single home. A trackable appears under every tag it has, and the nesting a screen shows is a view over the links rather than something stored: finding a trackable is the job of the views (the sidebar’s Group by, the list, search), not of a rule that it lives in one place. A tree would give each trackable one home, so its top level would have to mix axes: things you do and things you observe meet there, and Water belongs in both. Tags let a trackable sit under every axis it belongs to, and nesting counts, since a child counts as every tag above it.
A tag with nothing above it is an axis: Area, with Fitness, Nutrition and Rest under it, or Time of day, with Morning and Evening. Grouping by a tag at any depth works the same way, so there is no second concept for an axis.
Trackables with no tag are Untagged, a glossary term, and every screen that lists them says so: the sidebar, the trackables list and the Organize board head them Untagged, and the Tags page counts the untagged trackables (UNTAGGED in group-tree.ts).
Exclusive
Exclusive is a tag’s setting (presentation.exclusive, the Exclusive checkbox on a tag’s form): a trackable takes at most one of its child tags, a child counting with every tag under it. It is off unless set. A trackable under two is not refused. It raises an Issue, one per trackable and exclusive tag, reading for example “Fitness and Nutrition, and Area takes one”. The issue is undated, and its fingerprint is the tags it names, so dismissing it holds until those tags change. Its fix, Change tags, opens the trackable’s form at its Tags (/trackables/update?section=tags). The rule is exclusiveIssues in packages/core/src/issues.ts.
Storage
A tag is a trackable of kind none with the value schema { kind: "none" }. Tagging is a Membership edge between two trackables, membership(group_id, member_id, sort_order, is_aggregator), primary-keyed on the pair: many-to-many, ordered per edge (a trackable can be first under one tag and third under another), and removed with either end (onDelete: cascade). Deleting a tag therefore unlinks what it held, and the trackables themselves stay, Untagged if it was their only tag. There is no parent_id on a trackable.
Only a none trackable can hold members. canContain in packages/core/src/trackable-invariants.ts is the one predicate, asserted by membership.add and setMembers (adding under something that is not a tag) and by trackable.update (turning a tag that holds members into anything else). A derived trackable that holds members, a shape only data written outside the form can have, evaluates like any other, since nothing in the evaluator asks a container’s kind; it cannot gain members.
Cycles are refused in the membership repository (packages/core/src/membership-repository.ts), not by the database: adding A under B is refused when B is already reachable from A, and replacing a tag’s whole member list checks against every edge except that tag’s own, so a reorder cannot refuse itself. The list is written as a diff of the edges, so a member that stays is never briefly absent.
A tag’s summary
A tag has no value of its own, so the number a tag shows belongs to one of its members: a derived trackable inside it, nominated as its summary. The nomination is a flag on that member’s edge (is_aggregator), so it can only name a direct member, and only a derived one is offered, since a logged member says nothing about its siblings. The API takes a list and accepts at most one (MAX_AGGREGATORS). Exercised, a derived trackable inside Fitness reading Any over Fitness, is Fitness’s summary. A tag has no formula of its own because a derived trackable already is one: its summary is made in the same formula builder and read everywhere a derived trackable is, and the tag only points at it. The summary shows on the tag’s own page and nowhere else, since the sidebar shows every trackable as a colour and a name with no value.
It is nominated from two places: the tag’s form (Summarised by, under its members) and the tag’s own page, where it leads in one of three states: the summary’s current value with Clear, a picker of the tag’s derived members, or “Not summarised” with a link to make a derived trackable. The link opens the form already Derived, reading Any of everything tagged with the tag and tagged with it (/trackables/create?tag=), and saving returns to the tag’s page, where the new member is offered.
Reading a tag
A derived trackable reads a tag through a members ref, which walks the whole subtree when it is read (recursive, the default) or the direct members alone, so tagging something else changes it. The formula builder offers a tag as “Everything tagged Fitness” to Any and to a condition; Aggregate and Combine are offered trackables with a value. How a tag’s members fold under each reduce is in Readings: they read as one list of entries, and the tag’s own summaries are left out, so a summary inside its tag does not read itself. A sum over members that measure different things is not refused; the engine’s unit-blindness is in Derived Trackables.
Where tags show
- The Tags page (
/tags, in the nav): every top-level tag as a card, with how many trackables it covers and its child tags to open; a field to make a new tag; a link to the Organize board; and how many trackables have no tag. - A tag’s page (
/trackables/read?id=): its trail begins at Tags and runs through its ancestors. It shows its child tags as cards first, with a field to make one under it, then its summary, then its visualizations: the chart draws its members as small multiples (for a tag with none, “Nothing has this tag yet.”), the calendar and the table cover its whole subtree. It has no Log button and no Day, Timeline or Distribution. - The trail on a trackable’s or tag’s page names its tags as links (Trackables › Fitness › Cardio › Run). Membership is a graph, so
ancestorsinapps/renderer/src/components/layout/group-tree.tstakes one path: the first parent at each step, by the edge’ssort_order, then the parent’s name. - The trackables list (
/trackables) heads its rows by that same path (“Fitness › Cardio”), headings sorted by name so a parent’s heading sits above its children’s; Untagged trackables come last. - The sidebar navigates and never edits. Its Group by (kept per profile in the
sidebar:groupBysetting) is All tags, the graph as it nests with the Untagged after it; any one tag, its child tags the sections to full depth, a trackable under each place its tags put it and what it does not cover after them; or None, every trackable A to Z. - The trackable form has a Tags section, shown when there is a tag to take: a checkbox for every tag it could take, leaving out itself and its own descendants; saving adds and removes edges to match. A tag’s own form lists its members instead, with Exclusive and Summarised by.
- The Organize board (
/organize) arranges tags by dragging, below.
The Organize board
Every tag is a card of its direct members’ chips, and the Untagged card holds the trackables with no tag; a tag can be made inline. A tag nested under another appears both as a chip in its parent’s card and as a card of its own.
One useDragAndDrop session from @jeliroco-multimedia/blunt-ui covers the whole board, driven by the pointer, by touch and by the keys alike, so any move one can make the others can, moving a chip between tags included. The board is one tab stop. What a drop means is a pure function, dropAction in group-tree.ts:
- a gap beside a chip in the chip’s own tag reorders it;
- a gap in another tag moves it there at that place: the new tag’s whole member list is written first, cycle-checked, and only then is the old link removed, so a refused move never leaves the chip untagged;
- an empty tag’s drop line takes it as its only member; the Untagged card removes the link it was dragged from.
Targets are never nested, because the keys move to whatever target is nearest on screen: a chip in a tag is a target offering the gaps either side of it, an empty tag offers one line to drop on, and the Untagged card is one target whose chips only drag. A target that would refuse the held chip (a tag into itself or its own subtree, or a tag it is already in) is disabled for that drag, so neither the pointer nor the keys can land on it; the rule is canDropOnGroup. A pointer release lands on the target under it, else the nearest within 16 px, else nowhere (underOrNear from @jeliroco-multimedia/blunt-mechanisms/dnd). A touch starts a drag only from a chip’s grip, so a finger on a chip still scrolls the page; a mouse takes the chip anywhere. Focus follows the chip to where it lands, once the write has been read back. A chip that is itself a tag carries a badge reading Tag, and a derived trackable’s reads Derived, each its glossary term.
Related
Trackable · Derived Trackables · Readings · Visualizations · Jeliroco Components