Charts

Fatigue and Freshness

A four-state performance-management chart: fitness (CTL) and fatigue (ATL) as exponential averages of daily training stress, form (TSB) shaded around zero, race days flagged.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildFormRuns,
  buildPerformanceModel,
  formScale,
  formZone,
  loadScale,
  pickDayTicks,
  type ChartFatigueFreshnessData,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Fatigue and Freshness" performance-management chart
(zod, a cn() class merger, a useResizeObserver hook; hand-rolled SVG, no chart library).
It turns one training-stress number per day into the classic coaching picture: fitness (CTL),
fatigue (ATL), and form (TSB) shaded around zero, with race days flagged.

Contract
- zod schema, one source of truth (props are z.infer of it plus the render options below):
  status: "loading" | "empty" | "error" | "ready"
  title: string, caption?: string
  days: { date: string /* ISO YYYY-MM-DD */; tss: number >= 0 }[]   // any order; rest days may be absent
  events?: { date: string; label: string }[]                        // race days and other markers
- Render options on top of the contract: height (px, clamped 180-520), ctlDays (default 42),
  atlDays (default 7), startCtl / startAtl (seed values for the averages, default 0 = detrained),
  onRetry, className, ...div props. forwardRef to the card.
- All the maths live in exported pure functions beside the component, so a test can print the
  same numbers the picture is made of: parseDay, buildPerformanceModel, formZone, buildFormRuns,
  loadScale, formScale, pickDayTicks.

Behavior
- The model is an impulse-response EMA pair walked once over a GAPLESS daily series:
  ctl += (tss - ctl) / ctlDays and atl += (tss - atl) / atlDays. Days absent from the feed are
  filled in as 0-stress rest days first - skipping them would freeze fitness across a break
  instead of letting it decay. Count the fills and say so under the chart.
- Form is the classic TSB: **yesterday's** ctl - atl, because race-day freshness is the state
  you arrive with, not the state the race itself produces. Day one uses startCtl - startAtl.
- formZone(tsb) names the conventional coaching bands (> +25 transition, +5..+25 fresh - race
  window, -10..+5 neutral, -30..-10 productive training, < -30 overreaching risk) and feeds the
  headline, every readout sentence and the sr-only table.
- Feed hygiene, each repair counted and disclosed in a note, never silent: unreadable dates and
  non-finite / negative loads drop the entry; duplicate dates keep the first; unsorted input is
  sorted; events pointing outside the logged window are named as orphans rather than stretching
  the axis; past ~1500 days the OLDEST days are cut only AFTER the averages were computed over
  them, so the surviving numbers keep their warm-up.
- The form area is split at interpolated zero crossings (t = d0 / (d0 - d1) per segment) so
  positive and negative stretches are separate polygons; a day at exactly zero closes the open
  run and shades nothing, because zero form belongs to neither side.
- Reading it with a pointer: hit bands run to the midpoint between neighbouring days, so anywhere
  in the plot selects the nearest day and fills a readout line (load, fitness, fatigue, form,
  zone, event).
- Reading it with a keyboard: the plot is ONE tab stop with a roving tabindex over the days.
  Arrows step, Home / End jump to the ends, Page Up / Page Down jump between race days; movement
  clamps and never wraps. A gesture is never the only path to a number.
- Four states are first-class branches: loading (deterministic skeleton), empty, error (retry
  button only when onRetry is passed), ready. A ready feed with zero usable days falls through
  to the empty branch. One day paints dots, because a one-command subpath paints nothing.

Rendering & styling
- Semantic tokens only: var(--chart-1) for fitness, var(--chart-2) for fatigue, var(--chart-3)
  for positive form, var(--destructive) for negative form (deep fatigue is the one state the
  chart exists to warn about), plus --card, --foreground, --muted-foreground, --border, --ring.
  No hex, no rgb(), no oklch() anywhere, fills included.
- Colour is never the only channel: fitness is solid and fatigue dashed; the form split is
  carried by position (above vs below the zero rule) before it is repeated by colour.
- Two stacked panes share one x axis: load (CTL / ATL, scale anchored at 0) on top, form (TSB,
  scale always straddling zero, zero drawn as a dashed muted rule - a fact, not a tick) below.
- Event markers are a pennant plus a dashed rule through both panes, with a card-halo label
  (paint-order stroke) that flips to anchor "end" near the right edge so it never clips.
- Headline row: latest fitness, fatigue and signed form with its zone label, tabular-nums.
- SSR-stable and deterministic: the viewBox always matches the width the geometry was computed
  for, so the frame before the ResizeObserver reports is scaled to fit rather than clipped. No
  Math.random and no Date.now at render - the skeleton silhouette is a constant.
- Accessibility: the svg is role="group" (NOT role="img" - that would silence the focusable day
  stops), named by the heading and described by an sr-only summary. Each day rect carries a
  full-sentence aria-label; the visible readout line is aria-hidden (a focused day already
  announces itself) and a separate live region carries the pointer-driven readout. An sr-only
  wrapper div (never the table itself) holds a table repeating every number.
- Motion: the only animation is the loading pulse, gated with motion-reduce:animate-none, plus a
  motion-reduce-gated transition on the retry button. The chart is complete with animation off.

Customization levers
- Time constants: ctlDays / atlDays are the physiology - 42/7 is the road-cycling classic;
  shorten both for masters athletes or running, and keep atlDays well under ctlDays or form
  stops meaning anything. startCtl / startAtl seed a season that begins mid-training.
- Zone boundaries: the formZone bands are conventions, not physics - move them per sport and
  athlete, and consider surfacing only fresh / neutral / fatigued for a consumer-grade app.
- Density: height (180-520) and the LOAD_SHARE split between the panes are the two layout knobs;
  drop the headline row for a thumbnail embed, drop the form pane label when space is tight.
- Colour: swap the four tokens freely; keep positive and negative form on tokens that differ in
  more than hue, and keep negative form on a "warning-grade" token - that mapping is the point.
- Extend: future races - append zero-stress days up to the goal date upstream and the projection
  falls out of the same EMA; or plot raw daily TSS as faint bars in the load pane if athletes
  ask where the spikes were. Both are additions to the model, not rewrites.

Concepts

  • EMA pair — fitness and fatigue are the same daily load seen through two exponential averages with different memories (classically 42 and 7 days). Nothing else distinguishes them, which is why the time constants are contract-level options rather than styling.
  • Rest days decay the averages — a day with no entry is not "no data", it is a rest day: the model fills every calendar gap with zero stress before walking the EMAs, so a two-week break visibly erodes fitness instead of freezing the line.
  • Yesterday's balance — form (TSB) is the previous day's fitness minus fatigue, not today's: what matters on race morning is the state you arrive with. This one-day shift is the difference between a chart that predicts readiness and one that merely mirrors the workout.
  • Form shaded around zero — the sign is the message, so the area is split at interpolated zero crossings and the two sides wear different tokens; a positive/negative fill that shares one colour would bury the taper story the chart exists to tell.
  • Zones as words — raw TSB numbers mean nothing to most athletes, so every readout translates them through conventional coaching bands ("fresh — race window", "overreaching risk"); the boundaries are levers, not physics.
  • Race-day markers — events are flagged through both panes so "what was my form at the start line" is a glance, and one Page Down away on the keyboard; an event outside the logged window is named in the feed notes instead of silently stretching the axis.

On This Page