Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildOkrTreeLayout,
  inspectOkrTreeData,
  OKR_ROOT_ID,
  type ChartOkrTreeData,
  type OkrPace,
  type OkrTreeNode,
  type OkrWeightMode,
} from "./chart-okr-tree.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-okr-tree.json

Prompt

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.

On This Page