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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-focus-time.jsonPrompt
Build a React + TypeScript + Tailwind "ChartFocusTime" card in plain DOM
(percentage-positioned bands, no chart library and no canvas) with zod. It is a
per-person day timeline, and the point is not that it draws a calendar: the
point is that FOCUS BLOCKS ARE DERIVED, NEVER FED. The feed sends only booked
time; what is left over is the finding, and the fragmentation score falls out of
how that leftover is shaped.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; day?: string; timezone?: string;
dayStart: number; dayEnd: number; now?: number;
people: { id: string; name: string; role?: string;
entries: { id: string; label: string;
start: number; end: number }[] }[] }.
- EVERY TIME IS A MINUTE OF THE DAY, an integer in 0..1440 counted from local
midnight — not an instant, not an ISO string. This is the single decision
that keeps the whole component free of date bugs: the number the geometry
uses is the number printed on the axis, an ISO string is never sliced for
display, and a daylight-saving day cannot make "09:00 plus three hours" stop
being 12:00. State the cost out loud in the schema doc: an entry crossing
midnight belongs to the day it starts on and the feed splits it.
- `day` and `timezone` are printed VERBATIM and never parsed. A day timeline
that does not say whose 09:00 it means is unreadable on a distributed team.
- `now` is injected, never read from the clock: a component that calls
Date.now() renders one thing on the server and another in the browser and its
screenshots stop being reproducible.
- Component props = z.infer of the schema plus focusMinimumMinutes (default 60,
clamped 1..window width), trackHeight (default 26, clamped 14-56),
sort: "fragmentation" | "given" (default "fragmentation"), showShares and
showFootnote (default true), onSelectPerson, onRetry, className and the
native div props through forwardRef.
- Ship a pure module beside the schema: inspectFocusTimeData() for the
structural pass, formatClock() / formatDuration() / speakDuration(),
apportion() for whole per cents, medianOf(), focusTimeTicks() and
buildFocusTimeLayout() returning per-person rows plus the board totals.
Behavior — the derivation, which is the product
- CLIP first. Every entry is trimmed to [dayStart, dayEnd]; an entry that
misses the window entirely is dropped. Count both and say so in the footnote
— silently keeping a 07:00 gym session would make the shares add to more than
the working day.
- MERGE second, on `<=` and not `<`. Entries that merely touch leave no gap
between them, so they are one stretch of booked time even though they are two
meetings; entries that genuinely overlap are a double booking, which is
something calendars really do, so merge and record it rather than refusing.
Keep the internal boundaries as SEAMS and draw them — "one two-hour meeting"
and "four half-hour meetings in a row" leave identical open time and are not
the same day.
- The COMPLEMENT of the merged stretches inside the window is the open blocks.
A block at or above focusMinimumMinutes (inclusive) is unbroken focus; a
shorter one is a scrap. Everything else is arithmetic on those two piles.
- FRAGMENTATION = scrap minutes / open minutes, as a whole per cent. Say the
formula on the card. Guard the denominator: a fully booked day has NO open
time, so the score is null and the card prints an em dash — a day with
nothing free is a different finding from a day chopped to bits, and printing
100 for both destroys the distinction the component exists for. Clamp the
two ends so neither can lie: a day holding any usable block never prints 100,
a day holding any scrap never prints 0.
- The BOARD figure is the median of the per-person scores, over the people who
have any open time, by linear interpolation between closest ranks. Take the
two neighbouring ranks with floor and ceil and blend — never index an array
with a fractional index. With no such people the median is null and the
headline is the dash again.
- SHARES (focus / meetings / scraps) partition the window exactly, so
apportion them together by largest remainder with a total order on ties, or
the card prints 99% and 101%. Lift any part holding real time off zero, paid
for by the largest part.
- The default SPOTLIGHT is the worst readable day: highest fragmentation, then
most scrap minutes, then row order. Skip people with no open time as
candidates — leading the card with a dash wastes the one number that matters.
100 out of six hours of free time is a worse day than 100 out of half an
hour, which is exactly what the scrap-minutes tie-break encodes.
- The four states are first-class branches of one bg-card panel: four stepped
skeleton tracks (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.
status="ready" with no people falls through to the empty copy rather than
dividing by zero.
- Refuse in the contract, not in the renderer: a reversed or zero-length entry,
a duplicate id, a window with no width. A zero-length entry dropped quietly
would glue the open time on either side of it into one block and invent focus
that never happened, so name the entry, the person and the clock times.
Rendering & styling
- Layout: percentage-positioned bands inside one relative track per person, so
there is no ResizeObserver, no measurement pass and no viewBox to keep in
sync. left = (minute - windowStart) / windowMinutes, width the same for a
duration; give every band a 2px min-width so a fifteen-minute meeting is
still a mark on a nine-hour axis, and overflow-hidden on the track so the
min-width can never spill.
- Each row is a full-width button holding two lines — name, role and the row's
numbers on the first, the track on the second — so a narrow card wraps
instead of scrolling sideways and no fixed name column is needed. Because
every track is the same full width, one axis above them lines up with all of
them.
- Colour: exactly one accent, var(--chart-1), and it means focus everywhere it
appears. Booked time is bg-muted-foreground/30 plus a 45-degree hatch cut in
var(--card) — texture and not only hue, so the two read apart in greyscale
and for a reader with a colour vision deficiency, and the hatch inverts with
the theme for free. A scrap is bare track: the cheapest possible encoding for
"nothing lands here". THE ACCENT RUNS AT FULL STRENGTH IN ONE PLACE ONLY —
the longest usable block on the row the card is reading, which also carries
an inline duration label when it is wide enough to hold one; the same row's
other focus blocks sit at 0.55 and every other row's at 0.3. Hierarchy comes
from that one step, not from boxing each row in a card. The row being read
can leave NO usable block at all — that is the finding the card exists to
catch — and then there is nothing to accent: hold every focus band at 0.55
rather than dimming the board behind a highlight that is never drawn, drop
the accent dot, hold the legend key at the same strength, and have the
caption say that nothing on that day clears the minimum instead of promising
an accent that is not there. The inline label needs a scrim: there is no
--chart-1-foreground, and var(--card) on var(--chart-1) is 3.6:1 in the light
theme, under AA for 12px text — put it on a bg-card chip in
text-card-foreground, which is the card's own text pair and clears in both
themes.
- Typography: one oversized tabular numeral for the fragmentation score, one
small label naming whose score it is, one muted caption carrying the open
time, the longest block and the meeting load. Everything else is text-xs.
Generous whitespace between the headline, the timeline and the footnote; no
gradients, no glass, no glow.
- The now marker is a one-pixel rule in var(--foreground) at low opacity,
overlaid across the whole row list in a pointer-events-none layer padded to
match the rows, so it is a single element rather than one per track.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — how many people, the window, the three totals and their shares, the
median, how many people get no usable block at all, the spotlight's day, and
any double booking, clipping or dropping. Row buttons carry aria-pressed and
a focus-visible ring; the visible name stays in the accessible name and is
EXTENDED by an sr-only sentence rather than replaced by an aria-label (WCAG
2.5.3). The numeric meta on the row is aria-hidden because the same figures
are in that sentence. Tracks are aria-hidden decoration: below them an
sr-only WRAPPER DIV holds a real table with one row per person. Put sr-only
on the wrapper, never on the table — CSS width is only a lower bound for a
table box, so width:1px does not hold one back and a 375px viewport picks up
hundreds of px of horizontal scroll.
- Durations are spoken differently from how they are printed: "3h 45m" on the
picture, "3 hours 45 minutes" in every sr-only string, because a screen
reader reads the short form as "three h forty five m".
- Motion: the only animation is the skeleton pulse and a colour transition on
row hover and selection, both with motion-reduce variants. Nothing about
reading or operating the card depends on motion, and there is no timer,
observer or animation frame to tear down.
- Degenerate data, each handled deliberately: no people -> empty branch; a
person with no entries -> one block spanning the window, score 0; a fully
booked person -> no open time, score null, dash; every person fully booked ->
median null too; overlapping entries -> merged and counted once; entries
spilling past an edge -> clipped and counted; a single person -> the median
is that person's score; a window with no width -> refused before any
division.
Customization levers
- focusMinimumMinutes is the meaning dial, not a style one: 90 for deep work,
60 for a default knowledge-work day, 25 for a support rota where a pomodoro
is already a win. Everything downstream — which blocks are accented, the
score, the median, the share strip, the footnote wording — follows it.
- sort="given" restores the caller's order when the row order carries meaning
(a squad list, a rota); the default puts the worst day on top so the finding
is the first thing read.
- trackHeight plus showShares and showFootnote are the density dial: drop the
share strip first, the footnote second, the track height last.
- Swap the headline: lead with total unbroken focus, or with the longest block,
by re-pointing the big numeral at a different field of the same layout — the
derivation already computes all of them.
- Palette: the accent is a single token. Re-point it to var(--chart-2) to fit a
dashboard whose primary series is already chart-1, and keep booked time
neutral — the moment meetings get a hue of their own, the card stops having
one focal element.
- Interaction: onSelectPerson lifts the picked id, so a parent can sync a
detail panel, a filter or a table beside the chart. There is deliberately no
tooltip and no drag — this is a report, not an editor; wire a row to open the
person's calendar if you need one.Concepts
- Derived focus — nothing in the feed says "focus block". The component clips the calendar to the working window, merges what is booked, and calls the complement open time; a block only earns the name focus once it clears a stated minimum. That is why the card can disagree with the person whose calendar it is: they remember six free half-hours, and the card says none of them was long enough to be worth anything.
- Scrap — an open block below the minimum. Counting it as free time is the standard way a calendar audit flatters itself, so it is drawn as bare track, named in the legend, and is the whole numerator of the fragmentation score. Move the minimum and the same day changes verdict, which is the honest behaviour: "enough time to focus" is a policy, not a fact.
- Merged stretch with seams — back-to-back meetings leave no gap, so they are one stretch for every purpose the maths has, but the boundaries between them are kept and drawn as hairlines. One two-hour meeting and four half-hour meetings in a row produce identical open time and are not the same day, and a picture that hides the difference is lying by omission.
- Guarded denominator — fragmentation divides scrap minutes by open minutes, and a fully booked day has none. The component reports that as an em dash rather than as 100: "no free time at all" and "free time in useless pieces" are different findings with different fixes, and collapsing them is the one bug that would make the score worthless.
- Spotlight — the card leads itself. With nobody picked it reads the worst day it can quote, breaking ties on scrap minutes so that a score of 100 out of six free hours outranks 100 out of half an hour. Picking a row hands the headline, the share strip and the full-strength accent to that person; picking it again gives them back to the board.
- One accent, one focal element — the accent means focus wherever it appears, but it only runs at full strength on the longest usable block of the row being read. Every other focus band is the same hue held back, so a nine-row board still has exactly one thing the eye lands on first, and the hierarchy survives a theme swap because it is built from one token and two opacities rather than from six colours. When the row being read has no usable block at all — which is precisely the day this card is built to expose — there is nothing to run at full strength, so the board is held at one strength instead and the caption names the absence rather than pointing at an accent that was never drawn.
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.
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.