For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Activity Heatmap

A year of daily counts as a GitHub-style grid — a column per week, a row per weekday, one square per day, shaded by how much happened.

Agent runs

5,955 runs in the last 12 months

Daily activity from Sep 21, 2025 to Sep 26, 2026, 5955 in total.
OctNovDecJanFebMarAprMayJunJulAugSep
No runs on Sep 21, 202515 runs on Sep 28, 2025No runs on Oct 5, 202510 runs on Oct 12, 20255 runs on Oct 19, 20253 runs on Oct 26, 2025No runs on Nov 2, 202517 runs on Nov 9, 20255 runs on Nov 16, 20253 runs on Nov 23, 2025No runs on Nov 30, 20257 runs on Dec 7, 20251 runs on Dec 14, 2025No runs on Dec 21, 202521 runs on Dec 28, 20251 runs on Jan 4, 2026No runs on Jan 11, 20266 runs on Jan 18, 20264 runs on Jan 25, 20261 runs on Feb 1, 2026No runs on Feb 8, 20267 runs on Feb 15, 20265 runs on Feb 22, 20262 runs on Mar 1, 20261 runs on Mar 8, 202655 runs on Mar 15, 20263 runs on Mar 22, 20261 runs on Mar 29, 20261 runs on Apr 5, 202663 runs on Apr 12, 20266 runs on Apr 19, 20262 runs on Apr 26, 2026No runs on May 3, 20265 runs on May 10, 20262 runs on May 17, 2026No runs on May 24, 2026No runs on May 31, 20268 runs on Jun 7, 20262 runs on Jun 14, 2026No runs on Jun 21, 202612 runs on Jun 28, 20261 runs on Jul 5, 2026No runs on Jul 12, 202614 runs on Jul 19, 20265 runs on Jul 26, 20261 runs on Aug 2, 2026No runs on Aug 9, 202693 runs on Aug 16, 20265 runs on Aug 23, 20264 runs on Aug 30, 2026No runs on Sep 6, 2026101 runs on Sep 13, 20266 runs on Sep 20, 2026
Mon34 runs on Sep 22, 202517 runs on Sep 29, 202535 runs on Oct 6, 20259 runs on Oct 13, 20255 runs on Oct 20, 20251 runs on Oct 27, 202538 runs on Nov 3, 202510 runs on Nov 10, 20252 runs on Nov 17, 20251 runs on Nov 24, 202513 runs on Dec 1, 20254 runs on Dec 8, 202561 runs on Dec 15, 202548 runs on Dec 22, 202525 runs on Dec 29, 2025No runs on Jan 5, 202612 runs on Jan 12, 20266 runs on Jan 19, 20264 runs on Jan 26, 2026No runs on Feb 2, 202622 runs on Feb 9, 20268 runs on Feb 16, 20265 runs on Feb 23, 20261 runs on Mar 2, 2026179 runs on Mar 9, 202611 runs on Mar 16, 20262 runs on Mar 23, 20261 runs on Mar 30, 2026No runs on Apr 6, 202613 runs on Apr 13, 20262 runs on Apr 20, 2026No runs on Apr 27, 202616 runs on May 4, 20263 runs on May 11, 2026No runs on May 18, 202624 runs on May 25, 202618 runs on Jun 1, 20268 runs on Jun 8, 2026No runs on Jun 15, 202627 runs on Jun 22, 202614 runs on Jun 29, 202641 runs on Jul 6, 202632 runs on Jul 13, 20269 runs on Jul 20, 20263 runs on Jul 27, 202645 runs on Aug 3, 202635 runs on Aug 10, 202619 runs on Aug 17, 20263 runs on Aug 24, 20261 runs on Aug 31, 202628 runs on Sep 7, 202621 runs on Sep 14, 20264 runs on Sep 21, 2026
10 runs on Sep 23, 20251 runs on Sep 30, 202510 runs on Oct 7, 20251 runs on Oct 14, 2025No runs on Oct 21, 202541 runs on Oct 28, 202511 runs on Nov 4, 20251 runs on Nov 11, 202557 runs on Nov 18, 202544 runs on Nov 25, 20251 runs on Dec 2, 2025392 runs on Dec 9, 202523 runs on Dec 16, 202516 runs on Dec 23, 20251 runs on Dec 30, 202513 runs on Jan 6, 20264 runs on Jan 13, 20262 runs on Jan 20, 2026No runs on Jan 27, 202616 runs on Feb 3, 20264 runs on Feb 10, 20261 runs on Feb 17, 2026No runs on Feb 24, 202622 runs on Mar 3, 20267 runs on Mar 10, 20262 runs on Mar 17, 2026189 runs on Mar 24, 202625 runs on Mar 31, 202614 runs on Apr 7, 20263 runs on Apr 14, 2026216 runs on Apr 21, 202621 runs on Apr 28, 20264 runs on May 5, 2026No runs on May 12, 202626 runs on May 19, 20267 runs on May 26, 20264 runs on Jun 2, 20261 runs on Jun 9, 202629 runs on Jun 16, 20268 runs on Jun 23, 20265 runs on Jun 30, 202616 runs on Jul 7, 202611 runs on Jul 14, 20261 runs on Jul 21, 2026289 runs on Jul 28, 202617 runs on Aug 4, 202612 runs on Aug 11, 20264 runs on Aug 18, 2026316 runs on Aug 25, 202619 runs on Sep 1, 20268 runs on Sep 8, 20264 runs on Sep 15, 2026345 runs on Sep 22, 2026
Wed1 runs on Sep 24, 20255 runs on Oct 1, 20251 runs on Oct 8, 202538 runs on Oct 15, 202529 runs on Oct 22, 202513 runs on Oct 29, 20251 runs on Nov 5, 202542 runs on Nov 12, 202521 runs on Nov 19, 202514 runs on Nov 26, 202548 runs on Dec 3, 202514 runs on Dec 10, 20254 runs on Dec 17, 20252 runs on Dec 24, 202552 runs on Dec 31, 20254 runs on Jan 7, 2026No runs on Jan 14, 2026No runs on Jan 21, 202616 runs on Jan 28, 20265 runs on Feb 4, 2026No runs on Feb 11, 2026147 runs on Feb 18, 202619 runs on Feb 25, 20268 runs on Mar 4, 20261 runs on Mar 11, 2026No runs on Mar 18, 202613 runs on Mar 25, 20269 runs on Apr 1, 20263 runs on Apr 8, 2026No runs on Apr 15, 202615 runs on Apr 22, 20266 runs on Apr 29, 2026No runs on May 6, 202618 runs on May 13, 20264 runs on May 20, 20261 runs on May 27, 2026No runs on Jun 3, 202620 runs on Jun 10, 20269 runs on Jun 17, 20261 runs on Jun 24, 202610 runs on Jul 1, 20263 runs on Jul 8, 20261 runs on Jul 15, 202635 runs on Jul 22, 202619 runs on Jul 29, 20263 runs on Aug 5, 20261 runs on Aug 12, 2026No runs on Aug 19, 202621 runs on Aug 26, 20264 runs on Sep 2, 2026No runs on Sep 9, 2026No runs on Sep 16, 202623 runs on Sep 23, 2026
41 runs on Sep 25, 2025No runs on Oct 2, 202541 runs on Oct 9, 202512 runs on Oct 16, 20257 runs on Oct 23, 2025No runs on Oct 30, 202545 runs on Nov 6, 202513 runs on Nov 13, 20258 runs on Nov 20, 20252 runs on Nov 27, 202516 runs on Dec 4, 20252 runs on Dec 11, 2025No runs on Dec 18, 202556 runs on Dec 25, 20252 runs on Jan 1, 2026No runs on Jan 8, 202615 runs on Jan 15, 202611 runs on Jan 22, 20265 runs on Jan 29, 2026No runs on Feb 5, 202618 runs on Feb 12, 20269 runs on Feb 19, 20267 runs on Feb 26, 20261 runs on Mar 5, 202624 runs on Mar 12, 202614 runs on Mar 19, 20263 runs on Mar 26, 20261 runs on Apr 2, 2026No runs on Apr 9, 202616 runs on Apr 16, 20263 runs on Apr 23, 20262 runs on Apr 30, 202619 runs on May 7, 20265 runs on May 14, 2026No runs on May 21, 202629 runs on May 28, 202622 runs on Jun 4, 20265 runs on Jun 11, 20261 runs on Jun 18, 202632 runs on Jun 25, 20261 runs on Jul 2, 2026No runs on Jul 9, 202638 runs on Jul 16, 202612 runs on Jul 23, 20268 runs on Jul 30, 2026No runs on Aug 6, 202642 runs on Aug 13, 202613 runs on Aug 20, 20265 runs on Aug 27, 2026No runs on Sep 3, 202645 runs on Sep 10, 202625 runs on Sep 17, 20265 runs on Sep 24, 2026
Fri14 runs on Sep 26, 202529 runs on Oct 3, 20257 runs on Oct 10, 20251 runs on Oct 17, 2025No runs on Oct 24, 202532 runs on Oct 31, 202515 runs on Nov 7, 20251 runs on Nov 14, 2025No runs on Nov 21, 202552 runs on Nov 28, 20252 runs on Dec 5, 202553 runs on Dec 12, 202529 runs on Dec 19, 202520 runs on Dec 26, 2025No runs on Jan 2, 202615 runs on Jan 9, 20265 runs on Jan 16, 20263 runs on Jan 23, 2026No runs on Jan 30, 202618 runs on Feb 6, 20266 runs on Feb 13, 20264 runs on Feb 20, 20261 runs on Feb 27, 202626 runs on Mar 6, 20269 runs on Mar 13, 20261 runs on Mar 20, 2026No runs on Mar 27, 202629 runs on Apr 3, 202610 runs on Apr 10, 20264 runs on Apr 17, 2026No runs on Apr 24, 202613 runs on May 1, 20265 runs on May 8, 2026No runs on May 15, 202620 runs on May 22, 202610 runs on May 29, 20266 runs on Jun 5, 2026No runs on Jun 12, 202634 runs on Jun 19, 202611 runs on Jun 26, 202635 runs on Jul 3, 202627 runs on Jul 10, 202614 runs on Jul 17, 20262 runs on Jul 24, 2026No runs on Jul 31, 202621 runs on Aug 7, 202615 runs on Aug 14, 20262 runs on Aug 21, 2026No runs on Aug 28, 202624 runs on Sep 4, 202617 runs on Sep 11, 20266 runs on Sep 18, 2026No runs on Sep 25, 2026
1 runs on Sep 27, 20252 runs on Oct 4, 2025No runs on Oct 11, 202514 runs on Oct 18, 202511 runs on Oct 25, 20253 runs on Nov 1, 20251 runs on Nov 8, 202516 runs on Nov 15, 202512 runs on Nov 22, 20256 runs on Nov 29, 202518 runs on Dec 6, 20256 runs on Dec 13, 20254 runs on Dec 20, 20251 runs on Dec 27, 20253 runs on Jan 3, 20261 runs on Jan 10, 2026No runs on Jan 17, 2026No runs on Jan 24, 20264 runs on Jan 31, 20262 runs on Feb 7, 2026No runs on Feb 14, 2026No runs on Feb 21, 20267 runs on Feb 28, 20263 runs on Mar 7, 2026No runs on Mar 14, 20269 runs on Mar 21, 20265 runs on Mar 28, 20264 runs on Apr 4, 20261 runs on Apr 11, 2026No runs on Apr 18, 20266 runs on Apr 25, 20261 runs on May 2, 2026No runs on May 9, 20267 runs on May 16, 20262 runs on May 23, 20261 runs on May 30, 2026No runs on Jun 6, 20268 runs on Jun 13, 20262 runs on Jun 20, 2026No runs on Jun 27, 20264 runs on Jul 4, 20263 runs on Jul 11, 20261 runs on Jul 18, 202613 runs on Jul 25, 20264 runs on Aug 1, 20262 runs on Aug 8, 20261 runs on Aug 15, 202615 runs on Aug 22, 20268 runs on Aug 29, 20262 runs on Sep 5, 20261 runs on Sep 12, 2026No runs on Sep 19, 20269 runs on Sep 26, 2026
LessMore
"use client";

import { useState } from "react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/activity-heatmap.json

Usage

import { ActivityHeatmap } from "@/components/ui/activity-heatmap";
 
export function AgentActivity() {
  return <ActivityHeatmap className="text-chart-2" data={days} />;
}

days is one entry per day you have something to say about:

const days = [
  { date: "2025-03-03", value: 14 },
  { date: "2025-03-04", value: 41 },
  { date: "2025-03-05", value: 0 },
];

Days with no entry are drawn as empty squares rather than skipped, so a gap in the data looks like a gap. Pass Date objects or calendar-day strings; either way the grid shows the day you meant.

Data and dates

A contribution graph is about calendar days, not instants, and the two are easy to confuse. new Date("2025-03-04") is UTC midnight — which is March 3rd for anyone west of Greenwich, and a heatmap that is one day off is worse than no heatmap. So a plain YYYY-MM-DD string is read as a local calendar day, an ISO string with a time in it is parsed normally, and everything is then flattened to local midnight. Day arithmetic never touches epoch milliseconds, so nothing slips on a DST boundary either.

The grid ends on a real day — today unless end says otherwise — and starts on the first day of a week, so a year of history comes out as 53 columns rather than 52.1. Data past end extends the range instead of being hidden, because a component that silently drops rows is a component you stop trusting.

Levels

Five of them: empty, then four shades. The cut points between the shades are quantiles of the non-empty counts — the top quarter of days, the top half, and so on — with level 4 pinned to the 95th percentile rather than the maximum, so a dark square keeps meaning "that day was unusual" instead of "that was a Tuesday".

Quantiles are what GitHub uses and they are the right default for activity data, which is skewed: a handful of burst days would flatten a linear scale into one shade. Their cost is real, though — a level describes a day relative to the others, so the same count can change colour when the dataset does. When a level has to keep its meaning, pass the cut points:

// Fixed meaning: 1–4 runs is light, 40+ is the darkest.
<ActivityHeatmap data={days} thresholds={[4, 10, 20, 40]} />

Cut points are read from the whole dataset rather than the visible range, so narrowing the window rearranges the grid without repainting what is left of it.

When the level is known before the component sees it, put it on the datum and the scale is skipped:

{ date: "2025-03-04", value: 41, level: 4 }

Ties land on the lower level, so a plateau of identical days reads as one shade instead of splitting across two.

Range and geometry

PropDefaultDoes
weeks53Columns of history; 53 is a year and a day
weekStartsOn0Which day a column starts on, 0 Sunday to 6 Saturday
endtodayThe last day on the grid; data past it extends the range
cellSize11Side of a square, in px
gap3Space between squares, in px — and around the grid

53 columns is a year plus a day on purpose: it puts a given weekday in the same row as it was a year ago, which is what makes the vertical bands of a weekly rhythm line up.

Anything older than the first column is simply outside the grid — the window is weeks wide, not "everything you passed". Narrowing weeks is how a range switch is built.

The grid is wider than a phone, so it scrolls inside its own container with the page body left alone. Everything on it is sized in px rather than in a fluid unit — squares that resize with the viewport stop being a calendar and start being a chart.

Summarising is the caller's job. The component draws the grid; the line above it — "4,102 runs in the last 12 months" — belongs to whoever knows what a run is. If the summary and the grid have to agree, derive both from the same weeks the way the demo does.

Colour

The scale is one colour at five alphas rather than five hand-picked hexes: it is one decision instead of four, it holds on both themes, and it leaves the hue to the caller — so a token class at the call site is the whole palette.

<ActivityHeatmap className="text-chart-2" data={days} />

text-chart-2, text-primary, text-muted-foreground — anything that sets a text colour works, because the ramp is built from currentColor. Leaving className off is not an unset colour either: the root carries text-foreground, so the default is a neutral grey scale that reads as "no opinion" beside whatever accent the page already uses. className is merged rather than concatenated, so the caller's token wins over that default without an override prop.

Three layers decide what a square is painted with, each overriding the one under it:

LayerSet withUse it for
--heatmap-0 … -4stylea curated ramp, one level at a time
--heatmap-tintstylethe whole ramp in one line, e.g. var(--chart-3)
currentColorclassNamethe everyday case — any text-* token

The middle layer is the one that matters most in practice, because a chart palette is already a sequence:

// The whole scale, from one token, without touching the five levels.
<ActivityHeatmap data={days} style={{ "--heatmap-tint": "var(--chart-2)" }} />

The alphas are 12% for an empty day and 26 / 46 / 70 / 100% for levels 1–4. Level 0 is deliberately much lower than the next step: an empty square should read as empty, not as the first shade of busy.

Alpha rather than a fixed ramp is the one decision here worth defending. --chart-1 … --chart-5 are defined once and used in both themes, and in this theme they run from blue-300 to blue-800 — so mapping level 1 to --chart-1 and level 4 to --chart-4 would make the busiest days the darkest squares on a dark background, and the scale would read backwards. An alpha ramp cannot invert: it always moves away from whatever surface it is on, in either theme, with no second set of values to maintain.

When a curated ramp is what you want, define it per theme — the values below look right on light and would need a .dark counterpart that moves the other way:

<ActivityHeatmap
  data={days}
  style={{
    "--heatmap-0": "oklch(0.97 0 0)",
    "--heatmap-1": "oklch(0.87 0.08 150)",
    "--heatmap-2": "oklch(0.75 0.16 150)",
    "--heatmap-3": "oklch(0.6 0.16 150)",
    "--heatmap-4": "oklch(0.45 0.13 150)",
  }}
/>

Anything unset falls back down the chain, so setting one level does not cost the others.

Tooltip and locale

PropDefaultDoes
formatValuecount, or "No activity"The count in the tooltip
formatDateMar 4, 2025The date in the tooltip
localeen-USMonth, weekday and date names
legendtrueThe Less/More ramp under the grid
<ActivityHeatmap
  data={days}
  formatValue={(value) => `${value} tool calls`}
  formatDate={(date) => date.toDateString()}
/>

There is one tooltip for the whole grid, not one per square. It is moved and retyped through the DOM on hover, which is why a 371-square grid does not re-render when the pointer crosses it — the same string is already on the square as the label a screen reader reads. It is positioned fixed so that it escapes the grid's own scroll container; an absolutely positioned bubble would be clipped at the container's edge, which is exactly where a tooltip is most likely to want to go. It hides on scroll, on leaving the grid, and on hovering a day that has not happened yet.

locale is fixed rather than read from the browser so that the server and the client render the same string — hydration does not care which language you prefer, only that both sides agree. Pass Intl's locale string for the one you want.

Reduced motion

On mount the grid fills in column by column, left to right: a year of squares appearing at once reads as texture, and appearing as a wave reads as a year accumulating, which is the same story the numbers tell. It is a CSS animation, one per square, staggered 12ms a column and capped at 44 columns, so a longer history does not turn the tail into a pause.

With prefers-reduced-motion the animation is dropped and the grid is simply there. New squares that appear later — a live dataset gaining a day — animate on their own, because the wave is attached to the element rather than run by the component.

Accessibility

The grid is a real <table>: a column per week, a row per weekday, a <th scope="row"> on each weekday, a <th scope="colgroup"> per month. Table navigation is how a screen reader moves through it cell by cell, and each square carries its own label — "41 runs on Mar 4, 2025" — so a level never has to be inferred from a colour. A sr-only <caption> names the whole thing: the range it covers and the total it adds up to.

Squares are not individually focusable. 371 tab stops is a worse experience than none, and the content is reachable without them, so the grid is left out of the tab order entirely and the tooltip stays a pointer affordance.

Component source

activity-heatmap.tsx
"use client";

import { useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";

import { cn } from "@/lib/utils";

/* -- A year, as a grid ---------------------------------------------------------
 * One square per day, one column per week: the shape that daily counts of an agent
 * actually have — quiet weekends, a busy Tuesday, a run of dark squares in the week
 * something shipped. A sparkline shows the trend of one number; this shows the
 * *texture* of a habit, which is the thing you look at when deciding whether last
 * month was normal.
 *
 * The component is a table, a colour scale and one tooltip. Almost everything that
 * looks like a decision below is really about one of three things: what a level means,
 * where the days come from, and how little work a hover is allowed to cost.
 * --------------------------------------------------------------------------- */

/** A day's count. `level` short-circuits the scale when the caller already knows the
 *  bucket — for a fixed meaning that must not move when the data does. */
export interface HeatmapDatum {
  date: string | Date;
  value: number;
  level?: 0 | 1 | 2 | 3 | 4;
}

export interface ActivityHeatmapProps {
  /** One entry per day. Days with no entry are drawn empty rather than skipped. */
  data: HeatmapDatum[];
  /** Side of a square, in px. The gap is its own prop, not a ratio of this one. */
  cellSize?: number;
  className?: string;
  /** The last day on the grid. Defaults to today; data past it extends the range. */
  end?: string | Date;
  /** Space between squares, in px — and around the grid, since it is table spacing. */
  gap?: number;
  /** The tooltip's date. Defaults to `Mar 4, 2025`. */
  formatDate?: (date: Date) => string;
  /** The tooltip's count. Defaults to `12`, or `No activity` at zero. */
  formatValue?: (value: number) => string;
  /** Whether the Less/More ramp is drawn under the grid. */
  legend?: boolean;
  lessLabel?: string;
  /** Where month, weekday and date names are read from. Fixed rather than taken from
   *  the browser so the server and the client render the same string. */
  locale?: string;
  moreLabel?: string;
  style?: CSSProperties;
  /** The four cut points between levels 1–4. Pass them when a level has to keep its
   *  meaning across datasets; left out, they are quartiles of the data. */
  thresholds?: [number, number, number, number];
  /** Which day a column starts on, `0` Sunday to `6` Saturday. */
  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  /** How many columns of history. 53 weeks is a year and a day, so a given weekday
   *  lands in the same row it was in a year ago. */
  weeks?: number;
}

/** The default span: 53 columns, which covers a full year whatever day it ends on. */
const DEFAULT_WEEKS = 53;

/** Square side and spacing. 11 + 3 is the density GitHub settled on — big enough to
 *  hit with a pointer, small enough that a year fits a column without scrolling. */
const DEFAULT_CELL = 11;
const DEFAULT_GAP = 3;

/* -- The scale -----------------------------------------------------------------
 * Five steps: empty, then four shades of one colour. The shades are the colour at
 * 26/46/70/100% alpha rather than four hand-picked hexes, because that is one decision
 * instead of four, it holds on both themes, and it leaves the hue to the token the
 * caller already has — so `text-chart-2` at the call site is the entire colour story.
 *
 * Level 0 gets a much lower share of its own: an empty day is a square you can see is
 * empty, not a sixth colour. GitHub's ramp flattens toward nothing at both ends of its
 * scale for the same reason.
 * --------------------------------------------------------------------------- */
const LEVEL_SHARE = [12, 26, 46, 70, 100];

/** Where the default cut points fall among the non-empty counts, read as quantiles.
 *  The top one is not a quartile: level 4 is meant to be rare, so that a dark square
 *  still reads as "that day was unusual" rather than "that was a Tuesday". */
const QUANTILES = [0.25, 0.5, 0.75, 0.95];

/** Weekday rows that get a printed name, as `Date.getDay()` values. Naming every row
 *  would be a wall of text beside a grid small enough to read at a glance; Mon, Wed
 *  and Fri are enough to place any row. */
const LABELLED_WEEKDAYS = new Set([1, 3, 5]);

/** A month narrower than this many columns goes unlabelled — three letters need three
 *  squares of room, and a label that overhangs its own month is worse than none. The
 *  span is kept either way, so the next month still starts where it should. */
const MIN_MONTH_COLUMNS = 3;

/* -- The reveal -----------------------------------------------------------------
 * On mount the grid fills in column by column, left to right. This is the one piece of
 * motion here and it earns its place: a year of squares appearing all at once reads as
 * texture, and appearing as a wave reads as a year *accumulating*, which is the same
 * story the numbers tell. 12ms a column is a wave you notice at 53 columns and never
 * wait on; the cap keeps a longer history from turning the tail into a pause.
 * --------------------------------------------------------------------------- */
const REVEAL = "heatmap-cell-in";
const REVEAL_STEP = 12;
const REVEAL_CAP = 44;

/* -- Calendar maths -------------------------------------------------------------
 * All of it in local time, on purpose. A contribution graph is about calendar days,
 * not instants, and `new Date("2025-03-04")` is UTC midnight — which is March 3rd for
 * anyone west of Greenwich. The arithmetic below never touches epoch milliseconds, so
 * it also cannot slip an hour on a DST boundary.
 * --------------------------------------------------------------------------- */
const pad = (value: number) => String(value).padStart(2, "0");

const dayKey = (date: Date) =>
  `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;

const startOfDay = (date: Date) =>
  new Date(date.getFullYear(), date.getMonth(), date.getDate());

const addDays = (date: Date, days: number) =>
  new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);

/** Where a day sits in its week, counted from `weekStartsOn`. */
const dayIndex = (date: Date, weekStartsOn: number) =>
  (date.getDay() - weekStartsOn + 7) % 7;

/** A `YYYY-MM-DD` string is a calendar day and is read as one. Anything else is handed
 *  to `Date` and then flattened to the day it lands on locally. */
const toDay = (value: string | Date) => {
  if (value instanceof Date) {
    return startOfDay(value);
  }

  const calendar = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());

  if (calendar) {
    return new Date(
      Number(calendar[1]),
      Number(calendar[2]) - 1,
      Number(calendar[3])
    );
  }

  return startOfDay(new Date(value));
};

/** Nearest-rank quantiles of the counts — the same reading of "the top quarter of
 *  days" the levels are named after, and cheap enough to redo on every render. */
const quantiles = (values: number[], cuts: number[]) => {
  const sorted = values.toSorted((a, b) => a - b);

  return cuts.map((cut) => sorted[Math.floor((sorted.length - 1) * cut)] ?? 0);
};

/** How many cut points a count clears. Ties land on the lower level, so a plateau of
 *  identical days stays one shade instead of splitting across two. */
const levelOf = (value: number, thresholds: number[]) => {
  if (value <= 0) {
    return 0;
  }

  return Math.min(
    4,
    1 + thresholds.filter((threshold) => value > threshold).length
  );
};

/** The fill for a level, in three layers, each overriding the one under it: a
 *  per-level variable, then the shared tint, then `currentColor`. The tint is a
 *  variable of its own so that swapping the whole ramp for a token is one line —
 *  `--heatmap-tint: var(--chart-3)` — without giving up the plain `text-chart-2`
 *  route. */
const cellFill = (level: number) =>
  `var(--heatmap-${level}, color-mix(in oklab, var(--heatmap-tint, currentColor) ${LEVEL_SHARE[level]}%, transparent))`;

/** The days, keyed for lookup. Built once per render rather than searched per square:
 *  371 squares times a linear scan is the kind of thing that only shows up on someone
 *  else's laptop. */
const indexByDay = (data: HeatmapDatum[]) => {
  const byDay = new Map<string, HeatmapDatum>();

  for (const datum of data) {
    byDay.set(dayKey(toDay(datum.date)), datum);
  }

  return byDay;
};

/** The window on screen: the last day, the first day of its week, and every column
 *  between the two.
 *
 *  The grid ends on a real day — today unless told otherwise — and starts on the first
 *  day of a week, so the columns are weeks and the rows are weekdays. A year of history
 *  therefore comes out as 53 columns rather than 52.1. Data past `end` extends the
 *  range instead of being hidden: a component that silently drops rows is one you stop
 *  trusting. */
const rangeOf = ({
  data,
  end,
  weekStartsOn,
  weeks,
}: {
  data: HeatmapDatum[];
  end: string | Date | undefined;
  weekStartsOn: number;
  weeks: number;
}) => {
  const requestedEnd = end === undefined ? startOfDay(new Date()) : toDay(end);
  let latest: Date | null = null;

  for (const datum of data) {
    const day = toDay(datum.date);

    if (latest === null || day > latest) {
      latest = day;
    }
  }

  const endDay =
    latest !== null && latest > requestedEnd ? latest : requestedEnd;
  const startDay = addDays(
    endDay,
    -(dayIndex(endDay, weekStartsOn) + (weeks - 1) * 7)
  );

  return {
    columns: Array.from({ length: weeks }, (_, column) =>
      addDays(startDay, column * 7)
    ),
    endDay,
    startDay,
  };
};

/** The month labels, and which column each one starts at.
 *
 *  A label belongs to the column where the month turns over and to no other: it is the
 *  boundary that is worth printing, not the month's own first column. Every band keeps
 *  its width whether or not it keeps its label, so a month too narrow to name does not
 *  pull the next label out of alignment with its own squares. */
const monthBands = (columns: Date[], format: Intl.DateTimeFormat) => {
  const starts: { column: number; label: string }[] = [];
  let running = columns[0]?.getMonth() ?? -1;

  for (let column = 1; column < columns.length; column += 1) {
    const week = columns[column];

    if (week.getMonth() !== running) {
      starts.push({ column, label: format.format(week) });
      running = week.getMonth();
    }
  }

  return {
    bands: starts.map((start, index) => ({
      ...start,
      span: (starts[index + 1]?.column ?? columns.length) - start.column,
    })),
    leading: starts[0]?.column ?? columns.length,
  };
};

/** Everything the caption adds up, so the summary and the squares agree. */
const sumInRange = (data: HeatmapDatum[], start: Date, end: Date) => {
  let total = 0;

  for (const datum of data) {
    const day = toDay(datum.date);

    if (day >= start && day <= end) {
      total += datum.value;
    }
  }

  return total;
};

/* -- The tooltip -----------------------------------------------------------------
 * One bubble for the whole grid, moved and retyped directly through the DOM. A React
 * state per hover would re-render 371 squares to move one box, and a tooltip component
 * per square would mount 371 of them; the text it needs is already on the square as
 * `data-tooltip`, because the same string is what a screen reader reads.
 *
 * It is `fixed` rather than absolute so that it escapes the horizontal scroll container
 * the grid lives in — an absolutely positioned bubble gets clipped at the container's
 * edge, which is exactly where a tooltip is most likely to want to go.
 * --------------------------------------------------------------------------- */
const useHoverTooltip = () => {
  const bubble = useRef<HTMLDivElement>(null);
  const shown = useRef<HTMLElement | null>(null);

  const hide = () => {
    shown.current = null;

    if (bubble.current) {
      bubble.current.dataset.visible = "false";
    }
  };

  const show = (cell: HTMLElement) => {
    const node = bubble.current;
    const text = cell.dataset.tooltip;

    // Re-measuring the same bubble for every pixel of travel inside one square is work
    // that cannot change the answer.
    if (!(node && text) || shown.current === cell) {
      return;
    }

    shown.current = cell;
    node.textContent = text;
    node.dataset.visible = "true";

    const rect = cell.getBoundingClientRect();
    const half = node.offsetWidth / 2;
    const centre = rect.left + rect.width / 2;

    // Kept inside the viewport, or a square in the first or last column would push its
    // own label off-screen.
    node.style.left = `${Math.min(
      Math.max(centre, half + 8),
      window.innerWidth - half - 8
    )}px`;
    node.style.top = `${rect.top - 6}px`;
  };

  const onPointerOver = (event: ReactPointerEvent<HTMLTableElement>) => {
    const cell = (event.target as HTMLElement).closest<HTMLElement>(
      "[data-tooltip]"
    );

    if (cell) {
      show(cell);
    } else {
      // A day that has not happened yet has no text, and no tooltip should linger.
      hide();
    }
  };

  return { bubble, hide, onPointerOver };
};

/**
 * A year of daily counts as a GitHub-style grid: a column per week, a row per weekday,
 * one square per day, shaded by how much happened.
 *
 * The colour is a text colour: the default ink is `text-foreground`, and a token class at
 * the call site overrides it — `text-chart-2`, `text-primary`, anything. For a ramp of its
 * own, set `--heatmap-tint`, or `--heatmap-0` … `--heatmap-4` for a level at a time.
 *
 * Hovering a square raises one shared tooltip; nothing about hover lives in React
 * state, which is what keeps a 371-square grid from re-rendering on every pointer move.
 */
export const ActivityHeatmap = ({
  cellSize = DEFAULT_CELL,
  className,
  data,
  end,
  formatDate,
  formatValue,
  gap = DEFAULT_GAP,
  legend = true,
  lessLabel = "Less",
  locale = "en-US",
  moreLabel = "More",
  style,
  thresholds,
  weekStartsOn = 0,
  weeks = DEFAULT_WEEKS,
}: ActivityHeatmapProps) => {
  const { bubble, hide, onPointerOver } = useHoverTooltip();

  const byDay = indexByDay(data);
  const { columns, endDay, startDay } = rangeOf({
    data,
    end,
    weekStartsOn,
    weeks,
  });

  /* Cut points come from the whole dataset rather than the visible range, so that
   * shortening the range rearranges the grid without repainting what is left of it. */
  const scale =
    thresholds ??
    quantiles(
      data.map((datum) => datum.value).filter((value) => value > 0),
      QUANTILES
    );

  const monthFormat = new Intl.DateTimeFormat(locale, { month: "short" });
  const weekdayFormat = new Intl.DateTimeFormat(locale, { weekday: "short" });
  const dayFormat = new Intl.DateTimeFormat(locale, {
    day: "numeric",
    month: "short",
    year: "numeric",
  });
  const formatDay = formatDate ?? ((day: Date) => dayFormat.format(day));
  const formatCount =
    formatValue ??
    ((value: number) => (value <= 0 ? "No activity" : String(value)));

  const { bands, leading } = monthBands(columns, monthFormat);

  /** The row names, read off the first column so they follow `weekStartsOn`. */
  const rowLabels = Array.from({ length: 7 }, (_, row) => {
    const day = addDays(startDay, row);

    return LABELLED_WEEKDAYS.has(day.getDay())
      ? weekdayFormat.format(day)
      : null;
  });

  const total = sumInRange(data, startDay, endDay);

  return (
    <div
      className={cn("w-full text-foreground", className)}
      data-slot="activity-heatmap"
      style={style}
    >
      {/* Keyframes have to travel with the component: a registry install copies files,
          not a stylesheet. The same rules from two instances on one page are the same
          rule twice, so repeating them costs nothing. */}
      <style>{`@keyframes ${REVEAL}{from{opacity:0;transform:scale(0.72)}}
@media (prefers-reduced-motion: reduce){[data-heatmap-cell]{animation:none !important}}`}</style>

      <div className="overflow-x-auto" onScroll={hide}>
        <table
          className="border-separate"
          onPointerLeave={hide}
          onPointerOver={onPointerOver}
          style={{ borderSpacing: gap }}
        >
          <caption className="sr-only">
            {`Daily activity from ${formatDay(startDay)} to ${formatDay(
              endDay
            )}, ${total} in total.`}
          </caption>

          <thead>
            <tr>
              {/* The corner above the weekday names, and the columns before the first
                  month boundary — the grid starts mid-month most of the time. */}
              <th scope="col" />
              {leading > 0 ? <th colSpan={leading} scope="colgroup" /> : null}
              {bands.map((band) => (
                <th
                  className="pb-1 text-left align-bottom font-normal text-muted-foreground text-xs leading-none whitespace-nowrap"
                  colSpan={band.span}
                  key={band.column}
                  scope="colgroup"
                >
                  {band.span >= MIN_MONTH_COLUMNS ? band.label : null}
                </th>
              ))}
            </tr>
          </thead>

          <tbody>
            {rowLabels.map((label, row) => (
              <tr key={row}>
                <th
                  className="pr-2 text-right align-middle font-normal text-[10px] text-muted-foreground leading-none whitespace-nowrap"
                  scope="row"
                  style={{ height: cellSize }}
                >
                  {label}
                </th>

                {columns.map((week, column) => {
                  const day = addDays(week, row);
                  const datum = byDay.get(dayKey(day));
                  // Days past the end are the tail of the last week: kept so the last
                  // column is a column, drawn so it reads as "not yet".
                  const future = day > endDay;
                  const value = datum?.value ?? 0;
                  const level = future
                    ? 0
                    : (datum?.level ?? levelOf(value, scale));
                  const text = future
                    ? null
                    : `${formatCount(value)} on ${formatDay(day)}`;

                  return (
                    <td
                      aria-hidden={future ? true : undefined}
                      className="rounded-[2px] p-0"
                      data-heatmap-cell=""
                      data-tooltip={text ?? undefined}
                      key={dayKey(day)}
                      style={{
                        animation: future
                          ? undefined
                          : `${REVEAL} 320ms cubic-bezier(0.22, 1, 0.36, 1) ${
                              Math.min(column, REVEAL_CAP) * REVEAL_STEP
                            }ms backwards`,
                        backgroundColor: future ? undefined : cellFill(level),
                        height: cellSize,
                        width: cellSize,
                      }}
                    >
                      {text ? <span className="sr-only">{text}</span> : null}
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {legend ? (
        <div className="mt-3 flex items-center justify-end gap-1.5 text-muted-foreground text-xs">
          <span>{lessLabel}</span>
          {[0, 1, 2, 3, 4].map((level) => (
            <span
              aria-hidden="true"
              className="rounded-[2px]"
              key={level}
              style={{
                backgroundColor: cellFill(level),
                height: cellSize,
                width: cellSize,
              }}
            />
          ))}
          <span>{moreLabel}</span>
        </div>
      ) : null}

      <div
        aria-hidden="true"
        className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full rounded-md bg-primary px-2 py-1 font-medium text-primary-foreground text-xs whitespace-nowrap opacity-0 shadow-md transition-opacity duration-150 data-[visible=true]:opacity-100"
        data-visible="false"
        ref={bubble}
      />
    </div>
  );
};