Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildFocusTimeLayout,
  formatClock,
  formatDuration,
  inspectFocusTimeData,
  speakDuration,
  type ChartFocusTimeData,
  type FocusTimeRow,
} from "./chart-focus-time.contract"

Installation

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

Prompt

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.

On This Page