Blocker Impact
A four-state blocker ranking whose bar is a transitive blast radius counted once per task, split into what only that blocker holds versus what is also stuck behind someone else, with the board's held-up share apportioned to exactly 100 percent.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-blocker-impact.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBlockerImpact" card with zod — a
ranked list of blocked items whose headline number is a TRANSITIVE downstream
count. The number is the product: a blocker that directly holds two cards but
transitively holds thirty-one is the one to clear first, and a build that only
counts direct edges has answered a different, easier question.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; asOf?: string;
tasks: { id: string; title: string; owner?: string; blocks?: string[];
blocked?: boolean; blockedSince?: string; reason?: string }[] }.
- `tasks` is the WHOLE board, not just the blocked part. The denominator of
every share lives there, and an edge can only be resolved against ids the
feed carries.
- `blocks` is the DOWNSTREAM edge list — the ids this card holds up. Accept
that direction only. A feed that also carried `blockedBy` lets the two
disagree, and the whole card is a number derived from the edges, so an
ambiguous graph produces an authoritative-looking wrong answer.
- `blocked` is what puts a card into the ranking, and it is deliberately
separate from `blockedSince`: a board can know something is stuck without
knowing when it got stuck. "Age unknown" is honest; an age of 0 is a lie
that sorts to the bottom.
- Dates are `YYYY-MM-DD` or a datetime that CARRIES ITS ZONE. Refuse a bare
local datetime in the contract: the language parses it in the runtime's own
zone, so a server in UTC and a reader in Sydney compute ages hours apart and
React hydrates over the difference.
- `asOf` is the clock every age is measured against. Supplying it is what
makes the card reproducible; reading the system clock inside render is the
same hydration bug from the other end. With no `asOf`, fall back to the
newest `blockedSince` in the feed and say so in the footer.
- Component props = z.infer of the schema plus rankBy ("radius" | "age" |
"pressure", default "radius"), maxRows (default 6, clamped 1-20),
detailLimit (default 6, clamped 1-50), showHeadline and showBreakdown (both
default true), timeZone (default "UTC"), onRetry, className and the native
div props through forwardRef.
- Ship a pure module beside the schema: inspectBlockerImpactData() for the
structural pass, parseInstant(), reachDownstream(),
apportionWholePercents() and buildBlockerImpactLayout() returning the ranked
rows, the board buckets, the shares and every degenerate flag.
Behavior — the counting, which is the product
- BLAST RADIUS IS A DEDUPLICATED TRANSITIVE COUNT. Breadth-first walk from
each blocker over a visited Set; a card reachable by two different chains is
enqueued once and counted once. Summing each child's subtree size instead —
the obvious recursive shape — counts a shared dependency once per path into
it and inflates the headline by tens of percent on any board with fan-in.
- Seed the start node into the visited set without a level, so a blocker is
never its own dependent. If the walk comes back to it, the board has a
CYCLE: record the fact, keep the count, and never follow the loop round.
- SPLIT EACH RADIUS IN TWO. After every walk, count how many blockers reach
each card. Cards reached by exactly one are EXCLUSIVE to that blocker —
clearing it releases them. Cards reached by two or more are SHARED — they
stay stuck. A blocker whose radius is entirely shared is a real and common
shape, and the card has to be able to say "clearing this one alone releases
nothing".
- THE HEADLINE IS A UNION, NOT A SUM. Add up the radii and you have counted
overlapping cards repeatedly. Print the number of DISTINCT cards held up as
the oversized numeral, and when the sum is larger, state the difference in
the footer as double counting rather than leaving two plausible numbers on
the same card.
- Every card falls into exactly one bucket — held by two or more, held by
exactly one, held by none — so the three counts partition the board. Turn
them into whole percents by LARGEST REMAINDER (floor everything, hand the
leftover points to the biggest fractions, ties to the bigger count then the
earlier index). Rounding each share alone prints 99% or 101%, which a
segmented bar makes visible as bare track or a segment pushed off the end.
- AGE IS AN ELAPSED DURATION, not a calendar difference: whole 24-hour units
between `blockedSince` and the clock, floored, clamped at zero. Elapsed
units cannot be bent by a daylight-saving change the way "days between two
calendar dates" can. A start time after the clock is clamped to zero and
counted, never reported as negative.
- Ranking modes, all TOTAL orders so nothing reshuffles between renders:
"radius" (then age, then feed order), "age" (then radius), "pressure" =
radius x days, i.e. task-days of work held up (then radius). An unknown age
or pressure sorts BELOW a genuine zero — "we do not know" is not the same
claim as "no time at all".
- Edges are cleaned once, before any walk: a self-reference is dropped, an
edge pointing at an id the feed does not carry is dropped, duplicates
collapse. Count both kinds and say so — dropping can only make a radius too
small, so the printed numbers are lower bounds by that much. Do NOT refuse
the board over them; a paged or filtered export always has some, and hiding
the whole card is worse than a stated lower bound.
- What IS refused: two cards sharing an id (ids are how edges find their
target, so a duplicate silently merges two cards) and an unreadable date.
- The four states are first-class branches of one bg-card panel: a pulsing
skeleton (aria-hidden, plus an sr-only role="status"), an empty state, an
error state carrying either the transport message or the specific contract
issue plus a "Try again" button only when onRetry exists, and ready. Two
more ready shapes route to their own copy rather than dividing by zero:
zero tasks falls through to the empty branch, and zero blockers gets its own
"nothing is blocked" panel — that is the answer people opened the card
hoping for, not an error.
- Degenerate data, each handled deliberately: every radius zero (blocked, but
holding nothing up) draws empty tracks and says so instead of dividing by
its own maximum; one blocker means nothing can be shared; no dates at all
means no ages are shown; a single task means the shares are 0 / 0 / 100.
Rendering & styling
- Layout is generous rather than boxed. One card, no nested cards: an
oversized tabular numeral for the union, a small label under it, a muted
caption under that, then the board split, then the ranked rows separated by
hairlines. Hierarchy comes from size and weight contrast, not from framing
each block.
- ONE accent token, naming exactly one thing: work that is being held up. It
carries the two held segments of the board split and the rank-1 bar, and
nothing else. Every other bar is var(--foreground) at low alpha, which reads
on a light and a dark card alike. Keep the accent in a single constant so
re-pointing it is a one-line change.
- Each row is a three-column grid — rank, title, the ranked figure — with the
meta line and the bar spanning the last two columns underneath. The bar is
the radius against the worst radius on the board, its solid part exclusive
and its faint part shared, so the shape of the overlap is legible before any
number is read. Titles wrap; nothing is silently truncated.
- The board split is a single rounded track with two segments whose widths are
the APPORTIONED percents, so the picture and the printed numbers can never
disagree. The third bucket is the track itself. Colour is never the only
encoding — each bucket is named and counted in the legend beside it.
- Accessibility contract: rows are real buttons with aria-expanded and
aria-controls opening a panel that names what is held up, how many hops
away and whether it is shared. The accessible name comes from the visible
content — no aria-label replacing the title, which would break Label in
Name. Bars and the legend swatches are aria-hidden decoration; every figure
they encode is printed as text, and the ranked figure carries an sr-only
clause saying what it counts. Focus-visible rings on every control.
- Motion: the only animation is the skeleton pulse and a colour transition on
hover, both with motion-reduce variants. Nothing about reading or operating
the card depends on motion, and there are no timers, observers or animation
frames to leak.
- Dates are formatted through Intl in the timeZone prop, from the SAME instant
the age was computed from — never by slicing an ISO string, which prints the
UTC day to a reader who is not in UTC. Show the year only when it differs
from the clock's year.
Customization levers
- rankBy is the main dial: "radius" answers "what unblocks the most", "age"
answers "what has been ignored longest", "pressure" (radius x days)
answers "where has the most work-time accumulated" and is the one to use in
a weekly review.
- maxRows and detailLimit are the density dial; drop detailLimit to 3 for a
sidebar and maxRows to 3 for a standup tile. Whatever is folded is still
accounted for — the fold line says how many cards wait only on the hidden
blockers.
- showHeadline={false} turns the card into a dense list tile for a dashboard
grid; showBreakdown={false} drops the board split when the surrounding page
already shows board totals. Dropping both leaves a pure ranking.
- unit re-points every noun (issues, stories, PRs, orders); timeZone re-points
every printed date without touching the maths.
- The accent constant is the whole palette. Point it at var(--chart-5) for an
alarming board or var(--chart-1) for a calm one; the neutral bars follow the
foreground token and need no change.
- Interaction: the row disclosure is the only built-in behaviour. Wire a
drill-in by lifting the open id into a parent, or make the downstream titles
links to those cards — they are plain text on purpose, so nothing looks
clickable until you make it so.Concepts
- Blast radius — everything downstream of a blocker, counted once per card rather than once per path. It is deliberately transitive: the direct edge count is the easy question, and a card that directly holds two things while transitively holding thirty-one is exactly the one a standup keeps missing.
- Deduplicated traversal — the walk carries a visited set, so a card sitting at the bottom of two chains out of the same blocker contributes one. The recursive "sum of my children's subtrees" shape is the classic way this number silently inflates, and on a board with any fan-in it inflates by tens of percent.
- Exclusive versus shared — of everything a blocker holds up, some cards are reached by no other blocker and some are reached by several. Only the exclusive part is released when that blocker is cleared, which is why the bar is split rather than solid and why a blocker whose radius is entirely shared can honestly be described as freeing nothing on its own.
- Union, not sum — the headline counts distinct cards held up across every blocker, which is smaller than adding the radii whenever two blockers overlap. Both numbers are printed and the difference is named as double counting, because leaving two plausible totals on one card is how a dashboard loses an argument.
- Largest-remainder shares — held by two or more, held by exactly one, held by none is a genuine partition of the board, so its three percentages have to total exactly 100. Floor them all and hand the leftover points to the biggest fractional parts; rounding each on its own is how a segmented bar ends up with a sliver of bare track.
- Elapsed-day age against a supplied clock — ages are whole 24-hour units between the blocked timestamp and an
asOfthe caller passes in, so a daylight-saving change cannot add or drop a day and the server and the browser cannot disagree. With no clock supplied the newest blocked card becomes one, and the footer says so rather than letting a reader assume the ages are against today.
Focus Time
A four-state per-person day timeline that derives focus blocks from the gaps a calendar leaves, splits them into usable time and scraps at a stated minimum, and leads with the fragmentation score that falls out of the split.
Release Readiness
A four-state go/no-go board that rolls per-workstream checks, sign-offs and open blockers into one derived verdict, leads with the single number that reaches zero exactly when the release is clear, and refuses to call an unread board.