OKR Tree
A four-state objective tree whose parents are the weighted rollup of their children, with sibling weights that do not sum to 1 either normalised out loud or drawn at the scale they actually describe.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-okr-tree.jsonPrompt
Build a React + TypeScript + Tailwind "ChartOkrTree" card in plain DOM (no chart
library, no SVG plot) with zod. The product is not the indented list — it is the
arithmetic underneath it: a parent's progress is the weighted mean of its
children, and the one thing this component must never do is quietly decide what
to do about weights that do not sum to 1.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; rootLabel?: string;
cycle: { start: string; end: string }; asOf: string;
nodes: { id: string; label: string; parentId?: string | null;
owner?: string; weight?: number; progress?: number;
due?: string }[] }.
- The hierarchy is a FLAT list with parent pointers, not nested children: a feed
can send it in any order, and loops, orphans and duplicate ids become things
you can name in one pass instead of shapes that cannot be expressed.
- A node with children is an objective (progress is derived); a node without is
a key result (progress is measured). There is no `type` field — the data
decides.
- weight is a RELATIVE weight among siblings, default 1, non-negative. 0 is
legal and means "tracked here, does not count towards the parent".
- progress is attainment where 1 = target met, REQUIRED on a leaf and
deliberately unbounded above: clipping the input at 1 turns a 130% quarter
into a 100% one. On a node that has children it is the *stated* figure a human
typed; keep it, draw the rollup, and report the disagreement.
- All dates are "YYYY-MM-DD" calendar days. Parse them to UTC midnights and
round-trip the parse so "2026-09-31" is refused; difference UTC midnights for
every day count, because a local-midnight difference lands on 23 or 25 hours
twice a year and rounds to the wrong day.
- Component props = z.infer of the schema plus weightMode ("normalize" |
"strict", default "normalize"), defaultExpandedDepth (default 2),
defaultSelectedId, riskTolerance (0.1), offTrackTolerance (0.25),
formatPercent, onSelect, onRetry, className and the native div props through
forwardRef. Omit "title" AND "onSelect" from the native props before
intersecting — both collide.
- Ship a pure module beside the schema: inspectOkrTreeData() for the structural
pass, parseCalendarDay(), daysBetween(), apportion() and buildOkrTreeLayout()
returning the rolled-up tree, a depth-first order, and the notices.
Behavior — the arithmetic, which is the product
- ROLL UP POST-ORDER. A leaf reports what it measured; a branch is
sum(share_i x progress_i) over its children. A branch two levels up is
therefore a weighted mean of weighted means, and a key result's real pull on
the headline is the PRODUCT of the shares along its path. Say that in the UI —
it is the thing people get wrong when they read one of these.
- WEIGHTS THAT DO NOT SUM TO 1 ARE HANDLED EXPLICITLY, TWICE OVER.
"normalize" divides every sibling weight by the group's sum and prints the
divisor ("weights sum to 1.40, each child was divided by 1.40").
"strict" uses them as given: an over-allocated group runs on a scale wider
than 1 (mark where 100% falls), an under-allocated one leaves a hatched,
labelled remainder nobody claimed. Silently rescaling is the failure mode this
component exists to prevent.
- A group whose weights are ALL ZERO is not a division by zero, it is a
decision: fall back to an equal split and say so. Check every other
denominator too — no children, one child, all children identical, nothing
earned yet.
- PERCENTAGES THAT PARTITION A WHOLE USE LARGEST-REMAINDER APPORTIONMENT. Both
the weight split and the contribution split are partitions, and rounding each
share on its own prints 99% or 101% under a heading that claims to be a
breakdown of everything. Floor every share, hand the leftovers to the largest
remainders, break ties by index so the output is stable.
- CONTRIBUTION IS SHARE x PROGRESS, not weight: it answers "where did the
earned progress come from", which is a different question from "how much does
this count for". When nothing has been earned, say there is nothing to
attribute rather than printing an equal split of zero.
- PACE is elapsed time over the node's own window (cycle start to the node's due
date, or the cycle end), clamped to 0..1 so a snapshot taken after the cycle
closed reads 100% and "5 days over" instead of a negative count. A deadline at
or before the window opened gives a pace of 1, not a division by zero.
Classify progress − pace into ahead / on track / at risk / off track with the
two tolerance props, and never let colour be the only carrier: the band is a
word in every row.
- Selection FOLLOWS FOCUS and is single: the headline, the segmented bar and the
breakdown all read the selected node, so moving through the tree re-points one
number instead of opening a panel.
- The four states are first-class branches of one bg-card panel: a pulsing
headline-plus-rows skeleton (aria-hidden, plus an sr-only role="status"), an
empty state, an error state carrying either the transport message or the
specific contract refusal plus a "Try again" button only when onRetry exists,
and ready. status="ready" with no nodes falls through to the empty copy rather
than rolling up an empty tree.
- Refuse, with the reason, on: duplicate ids, an unknown parentId, a parent-
pointer loop, a negative or non-finite weight, a non-finite or negative
progress, an unmeasured leaf, an unparseable date, and a cycle that ends on or
before it starts. Each message names the node and says why the number would
otherwise be a lie.
Rendering & styling
- Hierarchy comes from SIZE AND WEIGHT CONTRAST, not from boxing everything:
one oversized bold numeral (text-5xl / sm:text-6xl, tabular-nums, tight
tracking) for the selected node's progress, one small medium label under it,
one muted caption ("pace 53% · due Sep 30 · owner …"), and the pace band as a
small medium word on the opposite side. Everything else is text-xs muted.
- ONE ACCENT PER VIEW: var(--chart-1) is spent only on the headline bar. Row
bars are bg-foreground at reduced opacity on bg-muted, the pace marker is
bg-foreground, the unallocated hatch is var(--border). No second hue, no
gradient, no glow.
- THE HEADLINE BAR IS THE PICTURE. Cut it into one segment per child of the
selected node, each as wide as that child's apportioned weight percent and
filled by that child's progress — so the filled length IS the weighted rollup
rather than a redrawing of it, and the segments match the printed breakdown
exactly because both come from the same apportioned integers. Separate
segments with a 1px border in var(--card). Overlay a bg-foreground tick where
a linear burn would have reached today.
- Semantic tokens only: bg-card, bg-muted, border, ring, text-muted-foreground,
text-destructive, var(--chart-1), var(--border). No hex, no rgb(), no oklch().
- Rows are a flat DOM list with role="tree" / role="treeitem" and aria-level,
aria-posinset, aria-setsize, aria-expanded and aria-selected. Flat rather than
nested <ul>s on purpose: a focus ring on a nested treeitem outlines its whole
subtree, and a flat list keeps the ring on the row and the keyboard model
trivial. Indent with depth-many 1px guide spans, not with a padding hack.
- Keyboard: one tab stop (roving tabindex on the selected row). ArrowUp/Down
walk the VISIBLE rows, ArrowRight opens a branch then steps into it,
ArrowLeft closes it then steps out, Home/End jump to the ends, Enter and Space
fold a branch and are always swallowed so Space cannot scroll the page.
Keyboard moves have to focus a row that may not have existed when the key was
pressed, so set a ref flag in the handler and focus in an effect after the
commit — never on mount, or the card steals focus from the page.
- The twisty is an aria-hidden span with a click handler, not a nested button: a
button inside a treeitem adds a tab stop the pattern says should not exist,
and letting the click fall through to the row keeps selection and DOM focus on
the row you just folded.
- Accessibility: no aria-label replaces visible text — qualify numbers with
sr-only words ("progress" before the percentage) so the row's own text is its
accessible name. Below everything, an sr-only WRAPPER DIV (never sr-only on
the table itself — CSS width is only a lower bound for a table box) holds the
finding as a sentence plus a table of every node, including the ones currently
folded away.
- Motion: bar widths and the twisty rotation transition; the skeleton pulses.
All three carry motion-reduce variants and nothing about reading the card
depends on any of them.
Customization levers
- weightMode is the policy dial: "normalize" for a board where the objective is
always 100% of itself, "strict" for a governance view where over- and
under-allocation are findings you want on the screen.
- defaultExpandedDepth is the density dial (0 = collapsed to the root, 2 = every
objective open, 3+ = sub-objectives open too); defaultSelectedId opens the
path to a node and points the headline at it.
- riskTolerance / offTrackTolerance re-cut the pace bands — widen them for a
research quarter, tighten them for a launch.
- formatPercent re-points every printed progress and pace figure (one decimal,
a 0–10 score, a localised percent). The apportioned breakdown stays whole
percent by design: it is a partition and has to add to 100.
- Drop the breakdown line for a dashboard tile, or the notices list if you
surface weighting problems elsewhere — but if you drop the notices, move them
somewhere, because silent normalisation is the bug this component prevents.
- Palette: the single accent is var(--chart-1); re-point it to your brand token
and the whole card follows, because nothing else is coloured.
- Interaction: onSelect gives you the selected node id (null for the synthetic
root) — wire it to a side panel, a check-in form or a router. There is no
tooltip and no drag; adding either is a change of interaction model, not a
variant.Concepts
- Weighted rollup — a parent never holds a number of its own; it is the weighted mean of its children, computed post-order from the leaves up. Because each level multiplies by the next, a key result's real pull on the headline is the product of the shares along its path — which is why a 50%-weighted key result under a 20%-weighted objective moves the top number by a tenth of what it looks like it should.
- Weight policy — the decision every OKR tool has to make and most make invisibly: what to do when a group's weights sum to 1.4 or 0.8. Normalising is fine, and the divisor gets printed; using the weights as given is also fine, and then the bar has to be honest about running past 100% or leaving a slice nobody claimed. The one option not on the table is rescaling without saying so.
- Largest-remainder apportionment — the weight split and the contribution split are partitions of a whole, so they are floored and the leftover units handed to the largest fractional remainders. Rounding each share independently is what prints a breakdown adding to 99%, and a reader who notices stops trusting every other number on the card.
- Contribution versus weight — weight says how much a key result counts for; contribution (share × progress) says how much of the progress actually earned came from it. A heavily weighted key result at 10% contributes less than a light one at 90%, and the breakdown line answers the second question because that is the one people ask when a number moves.
- Pace versus progress — the reference is time, not a plan: the share of the node's own window that has elapsed by the snapshot date. Progress minus pace is the only comparison the card makes, it is clamped so a closed cycle reads 100% rather than a negative remainder, and the resulting band is always a word, never only a colour.
- Selection follows focus — the tree is single-select with one tab stop, and moving through it re-points the headline, the segmented bar and the breakdown at once. Nothing opens, nothing is revealed on hover, and the row you folded keeps focus — so a keyboard user and a mouse user are looking at exactly the same card state.
Roadmap Swimlanes
A four-state roadmap board — epics as bars across coarse time buckets in per-team lanes, deliberately without dates or dependency arrows, leading on how loaded one quarter is and flagging the lanes that are over the capacity they stated.
Review Turnaround
A four-state review-turnaround card — one lane per reviewer on one shared time axis, the wait for a first look nested inside the wait to merge, a p90 tail whisker, the pooled team median as a dashed rule, and an oversized headline that retargets to whichever reviewer you pick.