ui-shrushank
componentcommit-graphsource: ok · refreshed
Commit Graph
A contribution heatmap paged by two-week sprints, drillable to the day.
npx shadcn add "https://raw.githubusercontent.com/Shrootiiii/ui/main/r/commit-graph.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
icons@phosphor-icons/react
Files1 · source cached 10/8/2026, 1:49:20 AM
components/ui/CommitGraph.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type FormEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
import {
GitCommitIcon as PhGitCommit,
CircleNotchIcon as PhCircleNotch,
FlameIcon as PhFlame,
ArrowCounterClockwiseIcon as PhReset,
ArrowLeftIcon as PhArrowLeft,
CaretLeftIcon as PhCaretLeft,
CaretRightIcon as PhCaretRight,
PlusIcon as PhPlus,
PencilSimpleIcon as PhPencil,
} from "@phosphor-icons/react";
// A sprint is a start and an end date, on any weekday. Sprints never overlap,
// but they may leave days between them (a holiday week, a hardening week), and
// those days belong to no sprint. The newest sprint runs to the later of its
// end and today: a team that forgets to start the next one still sees today's
// commits land somewhere.
const PLANNED_DAYS = 14;
// The longest sprint the date fields accept.
const MAX_SPRINT_DAYS = 56;
// A year of history, five sprints on screen at a time.
const SEEDED_SPRINTS = 26;
const VISIBLE_SPRINTS = 5;
// How far ahead a sprint can be scheduled when it is created early.
const MAX_LEAD_DAYS = 28;
const DAY_MS = 86_400_000;
const DAYS = 7;
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const COMMITS_KEY = "ui-commit-graph:commits";
const SPRINTS_KEY = "ui-commit-graph:sprints";
const THEME_KEY = "ui-commit-graph:theme";
// The server cannot know what day it is for the viewer, so the first paint
// renders against a fixed date and the client swaps in the real one during
// hydration (see useToday).
const SERVER_TODAY = "2026-09-29";
type ThemeId = "emerald" | "blue" | "violet" | "amber" | "rose";
interface Theme {
label: string;
swatch: string;
light: [string, string, string, string];
dark: [string, string, string, string];
}
const THEMES: Record<ThemeId, Theme> = {
emerald: { label: "Emerald", swatch: "#16A34A", light: ["#BBF7D0", "#4ADE80", "#16A34A", "#166534"], dark: ["#14532D", "#15803D", "#22C55E", "#86EFAC"] },
blue: { label: "Blue", swatch: "#2563EB", light: ["#BFDBFE", "#60A5FA", "#2563EB", "#1E3A8A"], dark: ["#1E3A8A", "#1D4ED8", "#3B82F6", "#93C5FD"] },
violet: { label: "Violet", swatch: "#7C3AED", light: ["#DDD6FE", "#A78BFA", "#7C3AED", "#4C1D95"], dark: ["#4C1D95", "#6D28D9", "#8B5CF6", "#C4B5FD"] },
amber: { label: "Amber", swatch: "#D97706", light: ["#FDE68A", "#FBBF24", "#D97706", "#92400E"], dark: ["#78350F", "#B45309", "#F59E0B", "#FCD34D"] },
rose: { label: "Rose", swatch: "#DB2777", light: ["#FBCFE8", "#F472B6", "#DB2777", "#831843"], dark: ["#831843", "#BE185D", "#EC4899", "#F9A8D4"] },
};
const THEME_IDS = Object.keys(THEMES) as ThemeId[];
// Dates are handled as whole UTC day numbers (days since 1970-01-01). Adding a
// day is adding one, and no daylight-saving change can make a day 23 hours long.
function dayNumber(iso: string): number {
const [y, m, d] = iso.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / DAY_MS);
}
function isoFromDay(day: number): string {
return new Date(day * DAY_MS).toISOString().slice(0, 10);
}
function weekdayOf(day: number): number {
return new Date(day * DAY_MS).getUTCDay();
}
// Month names by hand rather than toLocaleDateString: the server and the
// browser can disagree on locale, and text that differs between them is a
// hydration error.
function shortDate(day: number): string {
const d = new Date(day * DAY_MS);
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
}
function longDate(day: number): string {
return `${WEEKDAYS[weekdayOf(day)].slice(0, 3)}, ${shortDate(day)}`;
}
function relativeDay(day: number, today: number): string {
const diff = day - today;
if (diff === 0) return "today";
if (diff === -1) return "yesterday";
if (diff === 1) return "tomorrow";
return diff < 0 ? `${-diff} days ago` : `in ${diff} days`;
}
// Pure function of the calendar date, not of when the page is viewed: Sep 24
// has the same count today and next month. Weekends run at a third, so the
// weekday axis carries a real signal.
function seededCount(day: number): number {
const x = Math.sin((day + 11) * 12.9898 + 78.233) * 43758.5453;
const frac = x - Math.floor(x);
let count = 0;
if (frac > 0.94) count = 8 + (Math.floor(frac * 37) % 6);
else if (frac > 0.78) count = 4 + (Math.floor(frac * 23) % 4);
else if (frac > 0.35) count = 1 + (Math.floor(frac * 17) % 3);
return weekdayOf(day) % 6 === 0 ? Math.floor(count / 3) : count;
}
// Integer hash (Math.imul, exact in every engine), deliberately not the
// sin-based seededCount above. A bucketed level survives a 1-ULP difference
// between two engines' Math.sin; a message index or a SHA does not, and text
// that differs between the server and the client is a hydration error.
function hash32(seed: number): number {
let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return h >>> 0;
}
interface SprintRange {
start: number;
end: number;
}
// The demo's history: 26 back-to-back sprints of 12 to 16 days, the newest
// started five days ago. Uneven lengths put every sprint on a different
// weekday, which is what real teams get once a holiday or a slipped planning
// meeting has moved the start once or twice.
function seededRanges(today: number): SprintRange[] {
const starts = [today - 5];
for (let i = 1; i < SEEDED_SPRINTS; i++) {
starts.unshift(starts[0] - (12 + (hash32(i * 131 + 17) % 5)));
}
return starts.map((start, i) => ({ start, end: i < starts.length - 1 ? starts[i + 1] - 1 : start + PLANNED_DAYS - 1 }));
}
// A real deploy reads these from the git log (or the host's API). They are
// generated here for the same reason the counts are: the site is a static
// export, so the demo has to carry its own history.
const SUBJECTS = [
"fix: guard the tick generator against an empty range",
"feat: add orientation to the chart frame",
"refactor: pull the tooltip anchor out of the crosshair",
"fix: stop the axis rounding away a fifth of the plot",
"chore: bump framer-motion to 11.3.2",
"test: cover the quantile legend ticks",
"perf: batch the dot layer into one path",
"fix: round projected coords so SSR and the client agree",
"docs: note why the preview files are generated",
"feat: keyboard roving across the heat map cells",
"fix: dark ramp was two steps too light at level 3",
"refactor: hoist the shared spline into the frame",
"chore: regenerate the preview chunks",
"fix: legend toggle left the y domain in dead space",
"feat: sticky first column on the data table",
"style: align the tick labels to the baseline",
"fix: reset the day filter when the sprint changes",
"test: snapshot the empty state",
"perf: memoize the grid so hover stops re-rendering it",
"fix: focus ring vanished at the light end of the ramp",
"feat: copy button on the code block",
"refactor: drop the wrapper div around every cell",
"chore: pin the node version in CI",
"fix: tooltip clipped inside the scroll container",
"docs: rewrite the install steps for bun",
"feat: reduced-motion path for the reveal",
"fix: aria-label read the count without the day",
"revert: put the old easing back on the drawer",
"chore: delete the unused ramp helper",
"fix: off-by-one in the week boundary",
];
interface Commit {
sha: string;
time: string;
subject: string;
}
// Every stride is coprime with SUBJECTS.length, so walking the pool by one of
// them visits every subject before repeating: a day never lands the same commit
// message twice, which is what gives a hashed message away as fake.
const STRIDES = [7, 11, 13, 17, 19, 23];
function commitsFor(day: number, count: number): Commit[] {
const base = hash32(day * 31 + 7) % SUBJECTS.length;
const stride = STRIDES[hash32(day * 17 + 5) % STRIDES.length];
return Array.from({ length: count }, (_, i) => {
const h = hash32(day * 97 + i * 7919 + 3);
return { h, minutes: 8 * 60 + (h % 660) };
})
.sort((a, b) => a.minutes - b.minutes)
.map(({ h, minutes }, i) => ({
sha: h.toString(16).padStart(8, "0").slice(0, 7),
time: `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`,
subject: SUBJECTS[(base + i * stride) % SUBJECTS.length],
}));
}
function levelFor(count: number): 0 | 1 | 2 | 3 | 4 {
if (count <= 0) return 0;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 9) return 3;
return 4;
}
interface Day {
day: number;
count: number;
upcoming: boolean;
}
interface Sprint {
start: number;
end: number;
days: Day[];
status: "done" | "current" | "planned";
overBy: number;
total: number;
activeDays: number;
elapsedDays: number;
best: number;
}
// Where the newest sprint actually ends: its own end, or today if it has run
// past that with nothing started after it.
function effectiveEnd(range: SprintRange, isNewest: boolean, today: number): number {
return isNewest && range.start <= today ? Math.max(range.end, today) : range.end;
}
function buildSprints(ranges: SprintRange[], today: number, extra: Record<string, number>): Sprint[] {
return ranges.map((range, i) => {
const { start } = range;
const isNewest = i === ranges.length - 1;
const end = effectiveEnd(range, isNewest, today);
const days: Day[] = [];
for (let day = start; day <= end; day++) {
const upcoming = day > today;
days.push({ day, upcoming, count: upcoming ? 0 : seededCount(day) + (extra[isoFromDay(day)] ?? 0) });
}
const elapsed = days.filter((d) => !d.upcoming);
return {
start,
end,
days,
status: start > today ? "planned" : end < today ? "done" : "current",
overBy: end - range.end,
total: elapsed.reduce((sum, d) => sum + d.count, 0),
activeDays: elapsed.filter((d) => d.count > 0).length,
elapsedDays: elapsed.length,
best: elapsed.reduce((max, d) => Math.max(max, d.count), 0),
};
});
}
// The overview lays each sprint on the calendar, GitHub-style: rows are Sunday
// to Saturday, so a sprint that starts on a Wednesday opens three cells down
// its first column. Nulls pad the partial weeks at either end.
function calendarColumns(days: Day[]): (Day | null)[][] {
const cells: (Day | null)[] = [...Array<null>(weekdayOf(days[0].day)).fill(null), ...days];
while (cells.length % DAYS) cells.push(null);
return Array.from({ length: cells.length / DAYS }, (_, c) => cells.slice(c * DAYS, (c + 1) * DAYS));
}
// The detail view counts from the sprint's own first day instead, so a
// Wednesday sprint reads Wed to Tue across the top and two full rows down.
function sprintRows(days: Day[]): (Day | null)[][] {
return Array.from({ length: Math.ceil(days.length / DAYS) }, (_, r) => {
const row: (Day | null)[] = days.slice(r * DAYS, (r + 1) * DAYS);
while (row.length < DAYS) row.push(null);
return row;
});
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function parseDate(value: unknown): number | null {
return typeof value === "string" && ISO_DATE.test(value) ? dayNumber(value) : null;
}
// Stored as [{ start, end }] in ISO dates. Anything overlapping the sprint
// before it is dropped rather than guessed at, so a hand-edited value can
// never produce two sprints claiming the same day.
function parseRanges(raw: string | null): SprintRange[] | null {
if (!raw) return null;
try {
const list: unknown = JSON.parse(raw);
if (!Array.isArray(list)) return null;
const ranges = list
.map((item: unknown) => {
const { start, end } = (item ?? {}) as { start?: unknown; end?: unknown };
const s = parseDate(start);
const e = parseDate(end);
return s !== null && e !== null && e >= s ? { start: s, end: e } : null;
})
.filter((r): r is SprintRange => r !== null)
.sort((a, b) => a.start - b.start)
.filter((r, i, all) => i === 0 || r.start > all[i - 1].end);
return ranges.length > 0 ? ranges : null;
} catch {
return null;
}
}
function parseCommits(raw: string | null): Record<string, number> {
if (!raw) return {};
try {
const map: unknown = JSON.parse(raw);
return map && typeof map === "object" && !Array.isArray(map) ? (map as Record<string, number>) : {};
} catch {
return {};
}
}
const subscribeNever = () => () => {};
// Read through useSyncExternalStore rather than seeding useState from
// localStorage in an effect: the server snapshot is what both the server and
// the first client paint render, so there's nothing for hydration to disagree
// about, and no cascading setState-in-effect render. Raw strings, not parsed
// values, so the snapshot compares equal between reads.
function readKey(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function getSprintsSnapshot() {
return readKey(SPRINTS_KEY);
}
function getCommitsSnapshot() {
return readKey(COMMITS_KEY);
}
function getNullSnapshot() {
return null;
}
function getThemeSnapshot(): ThemeId {
const stored = readKey(THEME_KEY) as ThemeId | null;
return stored && stored in THEMES ? stored : "emerald";
}
function getThemeServerSnapshot(): ThemeId {
return "emerald";
}
// The viewer's local calendar date, not the UTC one: a commit at 9pm in
// California belongs to that evening, not to tomorrow.
function getTodaySnapshot(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function getTodayServerSnapshot(): string {
return SERVER_TODAY;
}
function writeKey(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
// no-op
}
}
function GitCommitIcon() {
return <PhGitCommit size={13} weight="bold" aria-hidden />;
}
function SpinnerIcon() {
return <PhCircleNotch size={13} weight="bold" aria-hidden />;
}
function FlameIcon() {
return <PhFlame size={11} weight="fill" aria-hidden />;
}
function ResetIcon() {
return <PhReset size={11} aria-hidden />;
}
function BackIcon() {
return <PhArrowLeft size={12} weight="bold" aria-hidden />;
}
function OlderIcon() {
return <PhCaretLeft size={12} weight="bold" aria-hidden />;
}
function NewerIcon() {
return <PhCaretRight size={12} weight="bold" aria-hidden />;
}
function PlusIcon() {
return <PhPlus size={12} weight="bold" aria-hidden />;
}
function PencilIcon() {
return <PhPencil size={11} weight="bold" aria-hidden />;
}
const FOCUS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400";
const INPUT =
"h-8 w-full min-w-0 rounded-md border border-stone-300 bg-stone-100 px-3 text-sm tabular-nums text-stone-900 outline-none focus:border-emerald-600 focus:ring-2 focus:ring-emerald-600/25 dark:border-stone-700 dark:bg-stone-950 dark:text-stone-100 dark:[color-scheme:dark] dark:focus:border-emerald-400 dark:focus:ring-emerald-400/25";
const EYEBROW = "text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500";
interface DraftError {
field: "start" | "end";
message: string;
}
interface Bounds {
min: number | null;
max: number;
}
interface SprintDatesFormProps {
title: string;
start: string;
end: string;
startBounds: Bounds;
endBounds: Bounds;
today: number;
error: DraftError | null;
hint: ReactNode;
submitLabel: string;
onStartChange: (value: string) => void;
onEndChange: (value: string) => void;
onSubmit: () => void;
onCancel: () => void;
}
// One form for both jobs, creating a sprint and changing an existing sprint's
// dates. A new sprint defaults to starting today because most sprints are
// created the day they start; the fields are there for the ones that are not.
function SprintDatesForm({
title,
start,
end,
startBounds,
endBounds,
today,
error,
hint,
submitLabel,
onStartChange,
onEndChange,
onSubmit,
onCancel,
}: SprintDatesFormProps) {
const shouldReduceMotion = useReducedMotion();
const startRef = useRef<HTMLInputElement | null>(null);
const id = useId();
const hintId = `${id}-hint`;
const todayIso = isoFromDay(today);
const canPickToday = start !== todayIso && today <= startBounds.max && (startBounds.min === null || today >= startBounds.min);
useEffect(() => {
startRef.current?.focus({ preventScroll: true });
}, []);
const submit = (event: FormEvent) => {
event.preventDefault();
if (!error) onSubmit();
};
return (
<motion.form
onSubmit={submit}
aria-label={title}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className="mt-3 rounded-xl border border-stone-200 p-3 dark:border-stone-800"
>
<p className="text-sm font-medium text-stone-900 dark:text-stone-100">{title}</p>
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<div className="flex h-4 items-center justify-between gap-2">
<label htmlFor={`${id}-start`} className={EYEBROW}>
Starts
</label>
{canPickToday && (
<button
type="button"
onClick={() => onStartChange(todayIso)}
className={`rounded text-[11px] font-medium text-emerald-700 hover:underline dark:text-emerald-400 ${FOCUS}`}
>
Today
</button>
)}
</div>
<input
ref={startRef}
id={`${id}-start`}
type="date"
required
value={start}
min={startBounds.min === null ? undefined : isoFromDay(startBounds.min)}
max={isoFromDay(startBounds.max)}
onChange={(event) => onStartChange(event.target.value)}
aria-invalid={error?.field === "start" ? true : undefined}
aria-describedby={hintId}
className={`mt-1 ${INPUT}`}
/>
</div>
<div>
<div className="flex h-4 items-center">
<label htmlFor={`${id}-end`} className={EYEBROW}>
Ends
</label>
</div>
<input
id={`${id}-end`}
type="date"
required
value={end}
min={endBounds.min === null ? undefined : isoFromDay(endBounds.min)}
max={isoFromDay(endBounds.max)}
onChange={(event) => onEndChange(event.target.value)}
aria-invalid={error?.field === "end" ? true : undefined}
aria-describedby={hintId}
className={`mt-1 ${INPUT}`}
/>
</div>
</div>
<p id={hintId} className={`mt-2 text-xs ${error ? "text-red-600 dark:text-red-400" : "text-stone-600 dark:text-stone-400"}`}>
{error?.message ?? hint}
</p>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className={`h-8 rounded-md border border-stone-300 px-3 text-xs font-medium text-stone-700 transition-colors duration-150 hover:bg-stone-100 active:scale-[0.98] dark:border-stone-700 dark:text-stone-200 dark:hover:bg-stone-800 ${FOCUS}`}
>
Cancel
</button>
<button
type="submit"
disabled={error !== null}
className={`h-8 rounded-md bg-emerald-700 px-3 text-xs font-medium text-white transition-colors duration-150 hover:bg-emerald-800 active:scale-[0.98] active:bg-emerald-900 disabled:pointer-events-none disabled:opacity-50 ${FOCUS}`}
>
{submitLabel}
</button>
</div>
</motion.form>
);
}
type Draft = { mode: "create" } | { mode: "edit"; index: number };
export function CommitGraph() {
const shouldReduceMotion = useReducedMotion();
const todayIso = useSyncExternalStore(subscribeNever, getTodaySnapshot, getTodayServerSnapshot);
const persistedSprints = useSyncExternalStore(subscribeNever, getSprintsSnapshot, getNullSnapshot);
const persistedCommits = useSyncExternalStore(subscribeNever, getCommitsSnapshot, getNullSnapshot);
const persistedTheme = useSyncExternalStore(subscribeNever, getThemeSnapshot, getThemeServerSnapshot);
// undefined means "no change this session, use what storage holds"; null
// means "cleared", which is different from never having been set.
const [sprintsOverride, setSprintsOverride] = useState<string | null | undefined>(undefined);
const [commitsOverride, setCommitsOverride] = useState<string | null | undefined>(undefined);
const [themeOverride, setThemeOverride] = useState<ThemeId | null>(null);
const [committing, setCommitting] = useState(false);
const [justCommitted, setJustCommitted] = useState(false);
const [selected, setSelected] = useState<number | null>(null);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
// Counted back from the newest page rather than forward from the oldest, so
// adding a sprint keeps you on the newest page instead of one behind it.
const [pagesBack, setPagesBack] = useState(0);
const [pageDir, setPageDir] = useState(0);
const [draft, setDraft] = useState<Draft | null>(null);
const [draftStart, setDraftStart] = useState("");
const [draftEnd, setDraftEnd] = useState("");
// A new sprint's end follows its start at the planned length until the end
// is picked by hand; after that the two move independently.
const [endTouched, setEndTouched] = useState(false);
const [announcement, setAnnouncement] = useState("");
const sprintRefs = useRef<(HTMLButtonElement | null)[]>([]);
const backRef = useRef<HTMLButtonElement | null>(null);
const draftTriggerRef = useRef<HTMLButtonElement | null>(null);
const returnFocusTo = useRef<number | null>(null);
const focusAfterCreate = useRef<number | null>(null);
const wasSelected = useRef<number | null>(null);
const today = dayNumber(todayIso);
const sprintsRaw = sprintsOverride === undefined ? persistedSprints : sprintsOverride;
const commitsRaw = commitsOverride === undefined ? persistedCommits : commitsOverride;
const theme = themeOverride ?? persistedTheme;
const customRanges = useMemo(() => parseRanges(sprintsRaw), [sprintsRaw]);
const ranges = useMemo(() => customRanges ?? seededRanges(today), [customRanges, today]);
const extra = useMemo(() => parseCommits(commitsRaw), [commitsRaw]);
const sprints = useMemo(() => buildSprints(ranges, today, extra), [ranges, today, extra]);
const sprintColumns = useMemo(() => sprints.map((sprint) => calendarColumns(sprint.days)), [sprints]);
const currentIndex = sprints.findIndex((sprint) => sprint.status === "current");
const current = currentIndex === -1 ? null : sprints[currentIndex];
const lastIndex = sprints.length - 1;
const maxStart = Math.max(0, sprints.length - VISIBLE_SPRINTS);
const windowStart = Math.min(Math.max(maxStart - pagesBack, 0), maxStart);
const windowEnd = Math.min(windowStart + VISIBLE_SPRINTS, sprints.length);
const visibleSprints = sprints.slice(windowStart, windowEnd);
// The window's own total, not the year's: the headline should count what is
// actually on screen.
const windowTotal = visibleSprints.reduce((sum, sprint) => sum + sprint.total, 0);
const atOldest = windowStart === 0;
const atNewest = windowStart >= maxStart;
const streak = useMemo(() => {
let count = 0;
for (let day = today; day >= ranges[0].start; day--) {
if (seededCount(day) + (extra[isoFromDay(day)] ?? 0) > 0) count++;
else break;
}
return count;
}, [today, ranges, extra]);
const detail = selected === null ? null : (sprints[selected] ?? null);
const isCurrentSprint = selected !== null && selected === currentIndex;
const delta =
selected === null || selected === 0 || detail?.status === "planned" ? null : sprints[selected].total - sprints[selected - 1].total;
const sprintLog = useMemo(
() => (detail === null ? [] : detail.days.map((d) => ({ ...d, commits: commitsFor(d.day, d.count) }))),
[detail],
);
const visibleDays = selectedDay === null ? sprintLog.filter((d) => d.count > 0) : sprintLog.filter((d) => d.day === selectedDay);
// Focus follows the view, in both directions: into the sprint it lands on the
// way back out, and on return it lands on the sprint you came from rather
// than at the top of the card.
useEffect(() => {
const was = wasSelected.current;
wasSelected.current = selected;
if (selected !== null && was === null) {
backRef.current?.focus({ preventScroll: true });
} else if (selected !== null && document.activeElement === document.body) {
// Stepping onto the first or last sprint disables the chevron that was
// pressed, and a disabled button drops focus to the page.
backRef.current?.focus({ preventScroll: true });
} else if (selected === null && was !== null && returnFocusTo.current !== null) {
sprintRefs.current[returnFocusTo.current]?.focus({ preventScroll: true });
returnFocusTo.current = null;
}
}, [selected]);
// A new sprint's block only exists after the render that adds it.
useEffect(() => {
if (focusAfterCreate.current === null) return;
sprintRefs.current[focusAfterCreate.current]?.focus({ preventScroll: true });
focusAfterCreate.current = null;
}, [sprints]);
const moveWindowTo = (start: number) => setPagesBack(maxStart - Math.min(Math.max(start, 0), maxStart));
// Stepping to a sprint also drags the overview window along, so coming back
// out lands on the sprint you were reading rather than where you started.
const openSprint = (si: number) => {
const target = Math.min(Math.max(si, 0), lastIndex);
returnFocusTo.current = target;
setSelected(target);
setSelectedDay(null);
setDraft(null);
moveWindowTo(Math.min(Math.max(windowStart, target - VISIBLE_SPRINTS + 1), target));
};
const page = (direction: -1 | 1) => {
setPageDir(direction);
moveWindowTo(windowStart + direction * VISIBLE_SPRINTS);
};
const closeDetail = () => {
setSelected(null);
setSelectedDay(null);
setDraft(null);
};
const activeTheme = THEMES[theme];
const ramp = (level: 0 | 1 | 2 | 3 | 4, isDark: boolean) => (level === 0 ? null : (isDark ? activeTheme.dark : activeTheme.light)[level - 1]);
const changeTheme = (id: ThemeId) => {
setThemeOverride(id);
writeKey(THEME_KEY, id);
};
const saveRanges = (next: SprintRange[]) => {
const raw = JSON.stringify(next.map((r) => ({ start: isoFromDay(r.start), end: isoFromDay(r.end) })));
writeKey(SPRINTS_KEY, raw);
setSprintsOverride(raw);
};
const handleCommit = () => {
if (committing) return;
setCommitting(true);
// A real deploy of this component would POST to /api/commits and read the
// updated count back; this demo simulates that round trip's latency and
// persists the result the same place a server response would end up
// caching to, so the count survives a reload without a real backend.
// Keyed by date, so tomorrow's commits land on tomorrow.
const latency = 420 + Math.round(Math.random() * 260);
window.setTimeout(() => {
const raw = JSON.stringify({ ...extra, [todayIso]: (extra[todayIso] ?? 0) + 1 });
writeKey(COMMITS_KEY, raw);
setCommitsOverride(raw);
setCommitting(false);
setJustCommitted(true);
window.setTimeout(() => setJustCommitted(false), 500);
}, latency);
};
const handleReset = () => {
writeKey(COMMITS_KEY, null);
writeKey(SPRINTS_KEY, null);
setCommitsOverride(null);
setSprintsOverride(null);
closeDetail();
setPagesBack(0);
setAnnouncement("Sprints and commits reset to the sample history.");
};
const draftStartDay = ISO_DATE.test(draftStart) ? dayNumber(draftStart) : null;
const draftEndDay = ISO_DATE.test(draftEnd) ? dayNumber(draftEnd) : null;
const newest = ranges[lastIndex];
// Where each field may land. A new sprint has to start after the newest one
// starts (otherwise it would swallow it whole) and at most four weeks out.
// An existing sprint keeps clear of its neighbours: nothing else moves when
// its dates change, so it can never take days that belong to another sprint.
const draftLimits = (() => {
if (draft === null) return null;
const anchor = draftStartDay ?? (draft.mode === "create" ? today : ranges[draft.index].start);
const endMax = (next: SprintRange | undefined) => (next ? Math.min(next.start - 1, anchor + MAX_SPRINT_DAYS - 1) : anchor + MAX_SPRINT_DAYS - 1);
if (draft.mode === "create") {
return {
start: { min: newest.start + 1, max: today + MAX_LEAD_DAYS },
end: { min: draftStartDay, max: endMax(undefined) },
};
}
const i = draft.index;
const prev = ranges[i - 1];
const next = ranges[i + 1];
return {
start: { min: prev ? prev.end + 1 : null, max: next ? next.start - 1 : today + MAX_LEAD_DAYS },
end: { min: draftStartDay, max: endMax(next) },
};
})();
// Creating a sprint closes the newest one the day before it starts. If the
// new one starts later than that, the newest keeps its own end (or today, if
// it had already run past it) and the days between belong to no sprint.
const newestEndAfterCreate = (start: number) => {
const runsTo = effectiveEnd(newest, true, today);
return start <= runsTo + 1 ? start - 1 : runsTo;
};
const draftError = ((): DraftError | null => {
if (draft === null || draftLimits === null) return null;
if (draftStartDay === null) return { field: "start", message: "Pick a date for the sprint to start." };
if (draftEndDay === null) return { field: "end", message: "Pick a date for the sprint to end." };
const { start, end } = draftLimits;
const i = draft.mode === "create" ? lastIndex + 1 : draft.index;
if (start.min !== null && draftStartDay < start.min) {
return draft.mode === "create"
? { field: "start", message: `Has to be after Sprint ${i} starts on ${longDate(start.min - 1)}.` }
: { field: "start", message: `Sprint ${i} ends ${longDate(start.min - 1)}, so this one can start ${shortDate(start.min)} at the earliest.` };
}
if (draftStartDay > start.max) {
return draft.mode === "edit" && draft.index < lastIndex
? { field: "start", message: `Sprint ${i + 2} starts ${longDate(start.max + 1)}, so this one has to start before it.` }
: { field: "start", message: `Sprints can be scheduled up to four weeks ahead, ${longDate(start.max)} at the latest.` };
}
if (draftEndDay < draftStartDay) return { field: "end", message: "The end date is before the start date." };
const next = draft.mode === "edit" ? ranges[draft.index + 1] : undefined;
if (next && draftEndDay >= next.start) {
return { field: "end", message: `Sprint ${i + 2} starts ${longDate(next.start)}, so this one has to end by ${shortDate(next.start - 1)}.` };
}
if (draftEndDay > end.max) {
return { field: "end", message: `Sprints run eight weeks at most, so ${longDate(end.max)} at the latest.` };
}
return null;
})();
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
// The consequence line. Dates decide more than this sprint's own length: a
// new sprint closes the one before it, and any sprint can leave days that
// belong to no sprint. The form says so before anything is saved.
const draftHint = (() => {
if (draft === null || draftStartDay === null || draftEndDay === null || draftEndDay < draftStartDay) return null;
const parts = [`${plural(draftEndDay - draftStartDay + 1, "day")}, starting ${relativeDay(draftStartDay, today)}.`];
if (draft.mode === "create") {
const closesOn = newestEndAfterCreate(draftStartDay);
if (closesOn !== newest.end) {
const length = closesOn - newest.start + 1;
parts.push(
closesOn < today
? `Sprint ${lastIndex + 1} closes ${shortDate(closesOn)} after ${plural(length, "day")}.`
: `Sprint ${lastIndex + 1} now ends ${shortDate(closesOn)}, ${plural(length, "day")} in all.`,
);
}
const gap = draftStartDay - closesOn - 1;
if (gap > 0) parts.push(`The ${plural(gap, "day")} between them belong to no sprint.`);
} else {
const i = draft.index;
const prev = ranges[i - 1];
const next = ranges[i + 1];
const gapBefore = prev ? draftStartDay - prev.end - 1 : 0;
const gapAfter = next ? next.start - draftEndDay - 1 : 0;
if (gapBefore > 0) parts.push(`Leaves ${plural(gapBefore, "day")} after Sprint ${i} in no sprint.`);
if (gapAfter > 0) parts.push(`Leaves ${plural(gapAfter, "day")} before Sprint ${i + 2} in no sprint.`);
if (!next && draftStartDay <= today && draftEndDay < today) {
parts.push("It keeps collecting commits past that until the next sprint starts.");
}
}
return parts.join(" ");
})();
const openDraft = (next: Draft, trigger: HTMLButtonElement) => {
draftTriggerRef.current = trigger;
setDraft(next);
if (next.mode === "create") {
setDraftStart(todayIso);
setDraftEnd(isoFromDay(today + PLANNED_DAYS - 1));
setEndTouched(false);
} else {
setDraftStart(isoFromDay(ranges[next.index].start));
setDraftEnd(isoFromDay(ranges[next.index].end));
setEndTouched(true);
}
};
const changeDraftStart = (value: string) => {
setDraftStart(value);
if (!endTouched && ISO_DATE.test(value)) setDraftEnd(isoFromDay(dayNumber(value) + PLANNED_DAYS - 1));
};
const changeDraftEnd = (value: string) => {
setDraftEnd(value);
setEndTouched(true);
};
const cancelDraft = () => {
setDraft(null);
draftTriggerRef.current?.focus({ preventScroll: true });
};
const submitDraft = () => {
if (draft === null || draftStartDay === null || draftEndDay === null || draftError) return;
const range = { start: draftStartDay, end: draftEndDay };
const span = `${longDate(range.start)} to ${longDate(range.end)}`;
if (draft.mode === "create") {
const index = ranges.length;
saveRanges([...ranges.slice(0, -1), { start: newest.start, end: newestEndAfterCreate(range.start) }, range]);
setDraft(null);
setPagesBack(0);
focusAfterCreate.current = index;
setAnnouncement(`Sprint ${index + 1} created, ${span}.`);
} else {
saveRanges(ranges.map((r, i) => (i === draft.index ? range : r)));
setDraft(null);
setSelectedDay(null);
draftTriggerRef.current?.focus({ preventScroll: true });
setAnnouncement(`Sprint ${draft.index + 1} now runs ${span}.`);
}
};
// Commit lands on today, so it is only offered where today is on screen: the
// overview page holding the current sprint, or that sprint's own view.
const currentOnScreen = currentIndex >= windowStart && currentIndex < windowEnd;
const showCommitAction = selected === null ? currentOnScreen : isCurrentSprint;
const showReset = Object.keys(extra).length > 0 || customRanges !== null;
const enter = shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 };
const cellFill = (level: 0 | 1 | 2 | 3 | 4, upcoming: boolean, className: string) =>
upcoming ? (
<span className={`absolute inset-0 border border-dashed border-stone-300 dark:border-stone-700 ${className}`} />
) : (
<>
<span className={`absolute inset-0 block dark:hidden ${className}`} style={{ backgroundColor: ramp(level, false) ?? "rgb(226 232 240)" }} />
<span className={`absolute inset-0 hidden dark:block ${className}`} style={{ backgroundColor: ramp(level, true) ?? "rgb(255 255 255 / 0.08)" }} />
</>
);
return (
<div
onKeyDown={(event) => {
if (event.key !== "Escape") return;
if (draft !== null) cancelDraft();
else if (selected !== null) closeDetail();
}}
className="w-full max-w-md rounded-2xl border border-stone-200 bg-white p-4 dark:border-stone-800 dark:bg-stone-900"
>
<p className="sr-only" role="status" aria-live="polite">
{announcement}
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
{detail === null || selected === null ? (
<div>
<div className="flex items-center gap-1.5">
<p className="text-sm font-semibold text-stone-800 dark:text-stone-100">Commit activity</p>
{streak > 0 && (
<motion.span
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="inline-flex items-center gap-0.5 rounded-full bg-orange-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-orange-600 dark:text-orange-400"
>
<FlameIcon />
{streak}
</motion.span>
)}
</div>
<p className="text-xs text-stone-500 dark:text-stone-500">
{windowTotal} commits in sprints {windowStart + 1} to {windowEnd}
</p>
<p className="text-[11px] text-stone-500 dark:text-stone-500">
{current
? `Sprint ${currentIndex + 1} began ${longDate(current.start)}, ${
current.overBy > 0
? `${plural(current.overBy, "day")} past its end date`
: `day ${today - current.start + 1} of ${current.end - current.start + 1}`
}`
: sprints[lastIndex].status === "planned"
? `Between sprints. Sprint ${lastIndex + 1} starts ${longDate(sprints[lastIndex].start)}`
: "Between sprints"}
</p>
</div>
) : (
<div>
<div className="flex items-center gap-1.5">
<button
ref={backRef}
type="button"
onClick={closeDetail}
className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-700 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-200 ${FOCUS}`}
>
<BackIcon />
All sprints
</button>
</div>
<div className="mt-1 flex items-center gap-1.5">
<button
type="button"
onClick={() => openSprint(selected - 1)}
disabled={selected === 0}
aria-label={`Sprint ${selected}, the sprint before this one`}
title={selected === 0 ? "No older sprint" : `Sprint ${selected}`}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300 ${FOCUS}`}
>
<OlderIcon />
</button>
<p className="text-sm font-semibold text-stone-800 dark:text-stone-100">Sprint {selected + 1}</p>
<button
type="button"
onClick={() => openSprint(selected + 1)}
disabled={selected === lastIndex}
aria-label={`Sprint ${selected + 2}, the sprint after this one`}
title={selected === lastIndex ? "No newer sprint" : `Sprint ${selected + 2}`}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300 ${FOCUS}`}
>
<NewerIcon />
</button>
{detail.status !== "done" && (
<span className="rounded-full bg-stone-900/[0.06] px-1.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:bg-white/10 dark:text-stone-500">
{detail.status === "current" ? "In progress" : "Planned"}
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="text-xs text-stone-600 dark:text-stone-400">
{longDate(detail.start)} to {longDate(detail.end)}, {plural(detail.end - detail.start + 1, "day")}
{detail.overBy > 0 && `, ${detail.overBy} past its end date`}
</p>
<button
type="button"
onClick={(event) => (draft?.mode === "edit" ? cancelDraft() : openDraft({ mode: "edit", index: selected }, event.currentTarget))}
aria-expanded={draft?.mode === "edit"}
className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 transition-colors hover:bg-emerald-500/10 dark:text-emerald-400 ${FOCUS}`}
>
<PencilIcon />
Edit dates
</button>
</div>
{selected > 0 && ranges[selected].start - ranges[selected - 1].end > 1 && (
<p className="text-[11px] text-stone-500 dark:text-stone-500">
Starts {plural(ranges[selected].start - ranges[selected - 1].end - 1, "day")} after Sprint {selected} ended
</p>
)}
<p className="text-xs text-stone-500 dark:text-stone-500">
{detail.status === "planned" ? (
`Starts ${relativeDay(detail.start, today)}`
) : (
<>
{detail.total} commits, {detail.activeDays} of {detail.elapsedDays} days active, busiest {detail.best}
{delta !== null && (
<span className={delta > 0 ? "text-green-600 dark:text-green-400" : undefined}>
{", "}
{delta > 0 ? "+" : ""}
{delta} vs S{selected}
</span>
)}
</>
)}
</p>
</div>
)}
<div className="flex items-center gap-1">
{showReset && selected === null && (
<button
type="button"
onClick={handleReset}
aria-label="Reset sprints and commits to the sample history"
title="Reset sprints and commits"
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 dark:hover:bg-white/10 dark:hover:text-stone-300 ${FOCUS}`}
>
<ResetIcon />
</button>
)}
{selected === null && (
<button
type="button"
onClick={(event) => (draft ? cancelDraft() : openDraft({ mode: "create" }, event.currentTarget))}
aria-expanded={draft?.mode === "create"}
className={`inline-flex items-center gap-1.5 rounded-full border border-stone-300 px-3 py-1.5 text-xs font-medium text-stone-700 transition-colors duration-150 hover:bg-stone-100 active:scale-[0.98] dark:border-stone-700 dark:text-stone-200 dark:hover:bg-stone-800 ${FOCUS}`}
>
<PlusIcon />
New sprint
</button>
)}
{showCommitAction && (
<button
type="button"
onClick={handleCommit}
disabled={committing}
className={`inline-flex items-center gap-1.5 rounded-full bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-800 disabled:pointer-events-none disabled:opacity-50 ${FOCUS}`}
>
{committing ? (
<motion.span animate={{ rotate: 360 }} transition={{ duration: 0.7, repeat: Infinity, ease: "linear" }}>
<SpinnerIcon />
</motion.span>
) : (
<GitCommitIcon />
)}
{committing ? "Committing…" : "Commit"}
</button>
)}
</div>
</div>
{draft !== null && draftLimits !== null && (
<SprintDatesForm
key={draft.mode === "create" ? "create" : `edit-${draft.index}`}
title={draft.mode === "create" ? `New sprint: Sprint ${ranges.length + 1}` : `Sprint ${draft.index + 1} dates`}
start={draftStart}
end={draftEnd}
startBounds={draftLimits.start}
endBounds={draftLimits.end}
today={today}
error={draftError}
hint={draftHint}
submitLabel={draft.mode === "create" ? "Create sprint" : "Save dates"}
onStartChange={changeDraftStart}
onEndChange={changeDraftEnd}
onSubmit={submitDraft}
onCancel={cancelDraft}
/>
)}
{detail === null || selected === null ? (
<motion.div key="overview" initial={enter} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}>
<div className="mt-4 flex items-center gap-1">
<button
type="button"
onClick={() => page(-1)}
disabled={atOldest}
aria-label="Show older sprints"
title="Older sprints"
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300 ${FOCUS}`}
>
<OlderIcon />
</button>
<motion.div
key={windowStart}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: pageDir * 14 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
className="flex-1 overflow-x-auto pb-1"
data-lenis-prevent
>
<div className="mx-auto flex w-max items-start gap-1">
{/* Weekday rows, GitHub's Mon / Wed / Fri, offset by the
sprint buttons' padding so they line up with the cells. */}
<div className="flex flex-col gap-[3px] p-1 pr-0" aria-hidden>
{WEEKDAYS.map((name, di) => (
<span key={name} className="h-[11px] text-[11px] leading-[11px] text-stone-500 dark:text-stone-500">
{di % 2 === 1 && di < 6 ? name.slice(0, 3) : ""}
</span>
))}
</div>
<div className="flex gap-1" role="group" aria-label={`Sprints ${windowStart + 1} to ${windowEnd}`}>
{visibleSprints.map((sprint, wi) => {
const si = windowStart + wi;
const isCurrent = si === currentIndex;
const isPlanned = sprint.status === "planned";
return (
<button
key={si}
type="button"
ref={(node) => {
sprintRefs.current[si] = node;
}}
onClick={() => openSprint(si)}
aria-label={`Sprint ${si + 1}${isCurrent ? " (current)" : isPlanned ? " (planned)" : ""}, ${longDate(sprint.start)} to ${longDate(sprint.end)}, ${sprint.total} commits. Open its heat map.`}
title={`Sprint ${si + 1}: ${longDate(sprint.start)} to ${longDate(sprint.end)}`}
className={`flex min-w-10 flex-col items-center rounded-lg p-1 transition-colors hover:bg-stone-900/[0.03] dark:hover:bg-white/[0.04] ${FOCUS}`}
>
<div className="flex gap-[3px]">
{sprintColumns[si].map((column, ci) => (
<div key={ci} className="flex flex-col gap-[3px]">
{column.map((cell, di) => {
if (cell === null) return <span key={di} aria-hidden className="h-[11px] w-[11px]" />;
const isToday = cell.day === today;
return (
<motion.div
key={di}
aria-hidden
title={`${isToday ? "Today, " : ""}${longDate(cell.day)}: ${cell.upcoming ? "upcoming" : `${cell.count} commit${cell.count === 1 ? "" : "s"}`}`}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
animate={isToday && justCommitted ? { opacity: 1, scale: [1, 1.5, 1] } : { opacity: 1, scale: 1 }}
transition={
isToday && justCommitted
? { duration: 0.45, ease: [0.16, 1, 0.3, 1] }
: { duration: 0.25, delay: Math.min(wi * 4 + ci, 24) * 0.012, ease: [0.16, 1, 0.3, 1] }
}
className={`relative h-[11px] w-[11px] overflow-hidden rounded-[2.5px] ${isToday ? "ring-1 ring-emerald-500/70 ring-offset-1 ring-offset-white dark:ring-offset-stone-950" : ""}`}
>
{cellFill(levelFor(cell.count), cell.upcoming, "rounded-[2.5px]")}
</motion.div>
);
})}
</div>
))}
</div>
<span className="mt-2 flex flex-col items-center leading-tight">
<span
className={`text-[11px] uppercase tracking-[0.08em] ${
isCurrent ? "text-stone-500 dark:text-stone-400" : "text-stone-400 dark:text-stone-600"
}`}
>
S{si + 1}
</span>
<span
className={`text-[11px] tabular-nums ${
isCurrent ? "font-semibold text-stone-700 dark:text-stone-200" : "text-stone-500 dark:text-stone-500"
}`}
>
{isPlanned ? "next" : sprint.total}
</span>
<span className="whitespace-nowrap text-[11px] tabular-nums text-stone-400 dark:text-stone-600">
{shortDate(sprint.start)}
</span>
</span>
</button>
);
})}
</div>
</div>
</motion.div>
<button
type="button"
onClick={() => page(1)}
disabled={atNewest}
aria-label="Show newer sprints"
title="Newer sprints"
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-600 disabled:pointer-events-none disabled:opacity-50 dark:hover:bg-white/10 dark:hover:text-stone-300 ${FOCUS}`}
>
<NewerIcon />
</button>
</div>
</motion.div>
) : (
<motion.div
key={`sprint-${selected}-${detail.start}`}
initial={enter}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
>
<div className="mt-4">
{/* Headers start on the sprint's own first weekday, so a sprint
that began on a Wednesday reads Wed to Tue. */}
<div className="flex gap-2 pl-6" aria-hidden>
{Array.from({ length: DAYS }, (_, i) => WEEKDAYS[(weekdayOf(detail.start) + i) % DAYS]).map((day) => (
<span key={day} className="flex-1 text-center text-[11px] text-stone-500 dark:text-stone-500">
{day.slice(0, 3)}
</span>
))}
</div>
{sprintRows(detail.days).map((row, ri) => (
<div key={ri} className="mt-2 flex items-center gap-2">
<span className="w-4 shrink-0 text-[11px] uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500" aria-hidden>
W{ri + 1}
</span>
{row.map((cell, di) => {
if (cell === null) return <span key={di} aria-hidden className="h-11 flex-1" />;
const idx = ri * DAYS + di;
const level = levelFor(cell.count);
const isDaySelected = selectedDay === cell.day;
const isToday = cell.day === today;
const dayLabel = `${longDate(cell.day)}, ${cell.upcoming ? "upcoming" : `${cell.count} commit${cell.count === 1 ? "" : "s"}`}`;
return (
<motion.button
key={di}
type="button"
disabled={cell.upcoming}
onClick={() => setSelectedDay(isDaySelected ? null : cell.day)}
aria-pressed={cell.upcoming ? undefined : isDaySelected}
aria-label={`${isToday ? "Today. " : ""}${dayLabel}.${cell.upcoming ? "" : " Show this day's commits."}`}
title={longDate(cell.day)}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.24, delay: Math.min(idx, 20) * 0.012, ease: [0.16, 1, 0.3, 1] }}
className={`relative flex h-11 flex-1 items-center justify-center overflow-hidden rounded-lg text-xs font-semibold tabular-nums transition-transform hover:scale-[1.04] disabled:hover:scale-100 ${FOCUS} ${
isDaySelected
? "ring-2 ring-stone-900/40 ring-offset-2 ring-offset-white dark:ring-white/50 dark:ring-offset-stone-950"
: isToday
? "ring-1 ring-emerald-500/70 ring-offset-1 ring-offset-white dark:ring-offset-stone-950"
: ""
}`}
>
{cellFill(level, cell.upcoming, "rounded-lg")}
{/* The ramp runs dark-on-light one way and light-on-dark the
other, so the number needs its own pair, not one colour. */}
<span className={`relative block dark:hidden ${level >= 3 ? "text-white" : "text-stone-700"}`}>
{cell.count > 0 ? cell.count : ""}
</span>
<span className={`relative hidden dark:block ${level >= 3 ? "text-stone-900" : "text-stone-100"}`}>
{cell.count > 0 ? cell.count : ""}
</span>
</motion.button>
);
})}
</div>
))}
</div>
<div className="mt-4 border-t border-stone-200/70 pt-2 dark:border-white/10">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500">
{selectedDay === null
? "Commits, day by day"
: `${longDate(selectedDay)}, ${visibleDays[0]?.count ?? 0} commit${visibleDays[0]?.count === 1 ? "" : "s"}`}
</p>
{selectedDay !== null && (
<button
type="button"
onClick={() => setSelectedDay(null)}
className={`rounded-full px-1.5 py-0.5 text-[11px] text-stone-500 transition-colors hover:bg-black/5 hover:text-stone-700 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-200 ${FOCUS}`}
>
All days
</button>
)}
</div>
<div className="mt-1 max-h-44 overflow-y-auto pr-1" data-lenis-prevent>
{visibleDays.every((d) => d.count === 0) ? (
<p className="py-3 text-center text-[11px] text-stone-500 dark:text-stone-500">
{detail.status === "planned"
? `Nothing yet. Commits land here from ${longDate(detail.start)}.`
: selectedDay === null
? "No commits in this sprint yet."
: "No commits on this day."}
</p>
) : (
<ul className="space-y-2">
{visibleDays.map((d) => (
<li key={d.day}>
{selectedDay === null && (
<div className="flex items-baseline justify-between gap-2">
<p className="text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500">{longDate(d.day)}</p>
<p className="text-[11px] tabular-nums text-stone-400 dark:text-stone-600">
{d.count} commit{d.count === 1 ? "" : "s"}
</p>
</div>
)}
<ul className="mt-1 space-y-1">
{d.commits.map((commit, ci) => (
<li key={`${commit.sha}-${ci}`} className="flex items-baseline gap-2 text-[11px]">
<span className="tabular-nums text-stone-400 dark:text-stone-600">{commit.time}</span>
<span className="font-mono text-[11px] text-stone-500 dark:text-stone-500">{commit.sha}</span>
<span className="min-w-0 flex-1 truncate text-stone-600 dark:text-stone-300" title={commit.subject}>
{commit.subject}
</span>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</div>
</motion.div>
)}
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1.5" role="radiogroup" aria-label="Color theme">
{THEME_IDS.map((id) => (
<button
key={id}
type="button"
role="radio"
aria-checked={theme === id}
aria-label={THEMES[id].label}
title={THEMES[id].label}
onClick={() => changeTheme(id)}
className={`h-4 w-4 shrink-0 rounded-full transition-[color,background-color,border-color,transform,opacity] duration-150 ${FOCUS} ${
theme === id ? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-stone-950" : "opacity-60 hover:opacity-100"
}`}
style={{ backgroundColor: THEMES[id].swatch, ...(theme === id ? ({ "--tw-ring-color": THEMES[id].swatch } as CSSProperties) : {}) }}
/>
))}
</div>
<div className="flex items-center gap-1 text-[11px] text-stone-500 dark:text-stone-500">
Less
{([0, 1, 2, 3, 4] as const).map((level) => (
<span
key={level}
aria-hidden
className="h-[9px] w-[9px] rounded-[2px] dark:hidden"
style={{ backgroundColor: ramp(level, false) ?? "rgb(226 232 240)" }}
/>
))}
{([0, 1, 2, 3, 4] as const).map((level) => (
<span
key={level}
aria-hidden
className="hidden h-[9px] w-[9px] rounded-[2px] dark:block"
style={{ backgroundColor: ramp(level, true) ?? "rgb(255 255 255 / 0.08)" }}
/>
))}
More
</div>
</div>
</div>
);
}