Zum Inhalt springen
UI Kit
Accent

Kalender-Heatmap

Tage als getönte Zellen in einem vollständigen Kalender. Ein Jahr mit Schlüssel und vier Kennzahlen, Monatskalender mit Tageszahlen, mehrere Jahre untereinander und die längsten Serien zum Auswählen. Pfeiltasten gehen durch die Tage, auf dem Telefon scrollen die Wochen.

4 VariantsEine von 10 Components in Charts

Jeder Tag des Jahres in einem Bild

Eine Kalender-Heatmap tönt jeden Tag nach seinem Wert, Gewohnheiten, Jahreszeiten und volle Wochen zeigen sich so auf einen Blick. Sie geht über den Halbjahreskalender der Heatmap hinaus, die Pfeiltasten wandern durch die Tage und Telefone scrollen die Wochen.

„Jahr“ zeigt ein ganzes Jahr in Wochen mit dem Schlüssel und seinen Schwellen und vier Kennzahlen darüber, kommende Tage bleiben als Umriss. „Monatskalender“ zeichnet einen kleinen Kalender je Monat mit Tageszahlen und Monatssumme, zu lesen wie ein Wandkalender. „Mehrere Jahre“ legt drei Jahre untereinander auf dieselben Wochen mit der Summe jedes Jahres am Ende, Jahreszeiten und Wachstum liegen übereinander. „Serien“ lässt Tage, die das Ziel erreichen, Serien bilden, die längsten drei sind Schalter, eine gewählte wird umrandet und der Rest tritt zurück.

Wähle Schwellen, die deine Daten in gleich große Gruppen teilen, dann tragen die Farben Information. Zeig vier oder fünf Stufen, mehr Töne lassen sich schwer unterscheiden. Setz den Schlüssel direkt neben das Diagramm, eine Heatmap ohne Schlüssel ist nur ein Muster.

Variants

JahrNeu in 1.2

Ein ganzes Jahr in Wochen, der Schlüssel mit seinen Schwellen, vier Kennzahlen darüber. Kommende Tage bleiben als Umriss.

MonatskalenderNeu in 1.2

Ein kleiner Kalender je Monat mit Tageszahlen und Monatssumme, zu lesen wie ein Wandkalender.

Mehrere JahreNeu in 1.2

Drei Jahre untereinander auf denselben Wochen, die Summe jedes Jahres am Ende. Jahreszeiten und Wachstum liegen übereinander.

SerienNeu in 1.2

Tage, die das Ziel erreichen, bilden Serien. Die längsten drei sind Schalter, eine gewählte wird umrandet und der Rest tritt zurück.

Details

Was Kalender-Heatmap mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Charts
Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/calendar-heatmap.tsx
Bringt mit
utilskitchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 14 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle CalendarHeatmapProps aus components/ui/calendar-heatmap.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    CalendarHeatmapVariant

    yearmonthsyearsstreaks

  • daysPflicht
    CalendarDay[]

    One entry per day. Missing days count as zero.

  • fromoptional
    string

    First and last day shown. Default: the whole years (year, years, streaks) or months (months) of the data.

  • tooptional
    string
  • weekStartoptional
    0 | 1

    First day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English.

  • valueLabelPflicht
    string

    Name of the value in tooltip and table, e.g. "Workouts"

  • formatoptional
    Intl.NumberFormatOptions
  • levelsoptional
    number[]

    Lower bounds of the five strengths, smallest first. Default: from the largest value.

  • goaloptional
    number

    streaks: a day counts as active from this value on. Default 1.

  • statsoptional
    boolean

    year, streaks: the four numbers above the grid. Default true.

Eingebaute Texte

Diese Texte schreibt Kalender-Heatmap selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 14 Texte anzeigen
totalEnglisch TotalDeutsch Gesamt
activeEnglisch Active daysDeutsch Aktive Tage
bestEnglisch Best dayDeutsch Bester Tag
longestEnglisch Longest streakDeutsch Längste Serie
currentEnglisch Current streakDeutsch Aktuelle Serie
daysEnglisch {n} daysDeutsch {n} Tage
dayEnglisch 1 dayDeutsch 1 Tag
spanEnglisch {a} to {b}Deutsch {a} bis {b}
streakEnglisch StreakDeutsch Serie
streaksEnglisch Longest streaksDeutsch Längste Serien
noneEnglisch No streak yetDeutsch Noch keine Serie
dateEnglisch DateDeutsch Datum
summaryEnglisch {total} on {active} days, best day {best}.Deutsch {total} an {active} Tagen, bester Tag {best}.
streakOfEnglisch Part of a streak of {n} daysDeutsch Teil einer Serie von {n} Tagen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kalender-Heatmap reagiert selbst auf die Tasten Escape, Pos1, Ende und Leertaste.
  • Setzt aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 514 Zeilen
components/ui/calendar-heatmap.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, fill, numberFormatter, parseDate, rampColor, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Days as tinted cells, read as a calendar.
 *   year     a whole year in weeks, months on top, the key with its
 *            thresholds and four numbers above (total, active days,
 *            best day, longest streak)
 *   months   one small month calendar per month with the day numbers,
 *            the way a wall calendar reads
 *   years    several years stacked, one row each, its total at the end,
 *            so seasons and growth compare at a glance
 *   streaks  runs of active days, the longest three as toggles; a picked
 *            run is outlined and everything else steps back
 *
 * On phones the year scrolls sideways (no scrollbar, a fade where more
 * follows), starting at the latest week, weekdays stay in place. Arrow keys
 * walk the days: left and right a week (a day in the month view), up and
 * down a day (a week), Home and End jump to the first and last day.
 * heatmap has a half-year calendar mode, this one is the full calendar.
 */

export type CalendarHeatmapVariant = 'year' | 'months' | 'years' | 'streaks';

export interface CalendarDay {
  /** "2026-09-14" */
  date: string;
  value: number;
}

export interface CalendarHeatmapProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: CalendarHeatmapVariant;
  /** One entry per day. Missing days count as zero. */
  days: CalendarDay[];
  /** First and last day shown. Default: the whole years (year, years, streaks) or months (months) of the data. */
  from?: string;
  to?: string;
  /** First day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English. */
  weekStart?: 0 | 1;
  /** Name of the value in tooltip and table, e.g. "Workouts" */
  valueLabel: string;
  format?: Intl.NumberFormatOptions;
  /** Lower bounds of the five strengths, smallest first. Default: from the largest value. */
  levels?: number[];
  /** streaks: a day counts as active from this value on. Default 1. */
  goal?: number;
  /** year, streaks: the four numbers above the grid. Default true. */
  stats?: boolean;
  className?: string;
}

const TEXTS = {
  en: { total: 'Total', active: 'Active days', best: 'Best day', longest: 'Longest streak', current: 'Current streak', days: '{n} days', day: '1 day', span: '{a} to {b}', streak: 'Streak', streaks: 'Longest streaks', none: 'No streak yet', date: 'Date', summary: '{total} on {active} days, best day {best}.', streakOf: 'Part of a streak of {n} days' },
  de: { total: 'Gesamt', active: 'Aktive Tage', best: 'Bester Tag', longest: 'Längste Serie', current: 'Aktuelle Serie', days: '{n} Tage', day: '1 Tag', span: '{a} bis {b}', streak: 'Serie', streaks: 'Längste Serien', none: 'Noch keine Serie', date: 'Datum', summary: '{total} an {active} Tagen, bester Tag {best}.', streakOf: 'Teil einer Serie von {n} Tagen' },
};

const DAY = 864e5;
const GAP = 3;
const iso = (t: number) => new Date(t).toISOString().slice(0, 10);

/**
 * A calendar heatmap: one year, month calendars, several years or streaks.
 *   <CalendarHeatmap variant="year" valueLabel="Workouts" days={[{ date: '2026-03-14', value: 2 }, …]} />
 */
export function CalendarHeatmap(props: CalendarHeatmapProps) {
  const { variant = 'year', days, from, to, weekStart, valueLabel, format, levels, goal = 1, stats = true, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const ct = useTexts(CHART_TEXTS, labels);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const [wrapRef, width] = useWidth<HTMLDivElement>(760);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const { shown, open, jump } = useShown(active);
  const scrollRef = useRef<HTMLDivElement>(null);
  const [scroll, setScroll] = useState({ left: 0, max: 0 });
  const phone = width < 560;
  const name = label ?? title ?? valueLabel;
  const start = weekStart ?? (intl === 'de-DE' ? 1 : 0);
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const long = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
  const short = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', day: 'numeric', month: 'short' });
  const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
  const monthLong = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'long' });
  const wdF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'narrow' });
  const wdShort = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short' });

  // The range: whole years or whole months of the data
  const times = days.map((d) => parseDate(d.date)?.getTime()).filter((x): x is number => x !== undefined).sort((a, b) => a - b);
  const dataLo = times[0] ?? Date.UTC(2026, 0, 1);
  const dataHi = times[times.length - 1] ?? dataLo;
  const lo0 = new Date(dataLo);
  const hi0 = new Date(dataHi);
  const lo = from ? parseDate(from)!.getTime() : variant === 'months' ? Date.UTC(lo0.getUTCFullYear(), lo0.getUTCMonth(), 1) : Date.UTC(lo0.getUTCFullYear(), 0, 1);
  const hi = to ? parseDate(to)!.getTime() : variant === 'months' ? Date.UTC(hi0.getUTCFullYear(), hi0.getUTCMonth() + 1, 0) : Date.UTC(hi0.getUTCFullYear(), 11, 31);
  const count = Math.max(1, Math.round((hi - lo) / DAY) + 1);
  const byDate = new Map(days.map((d) => [d.date, d.value]));
  const vals = Array.from({ length: count }, (_, i) => byDate.get(iso(lo + i * DAY)) ?? 0);
  const dateOf = (i: number) => new Date(lo + i * DAY);
  // Days after the last data point are future days: drawn as empty outlines, not as zero
  const future = (i: number) => lo + i * DAY > dataHi;

  // Strengths
  const vMax = Math.max(0, ...vals);
  const th = levels?.length ? levels.slice(0, 5) : [1, Math.max(2, Math.ceil(vMax * 0.25)), Math.max(3, Math.ceil(vMax * 0.45)), Math.max(4, Math.ceil(vMax * 0.65)), Math.max(5, Math.ceil(vMax * 0.85))].filter((v, i, a) => i === 0 || v > a[i - 1]);
  const step = (v: number) => {
    let s = -1;
    th.forEach((x, k) => v >= x && (s = k));
    return s < 0 ? -1 : Math.round((s / Math.max(1, th.length - 1)) * 4);
  };

  // Stats and streaks
  const total = vals.reduce((a, b) => a + b, 0);
  const activeDays = vals.filter((v) => v >= goal).length;
  const bestI = vals.reduce((b, v, i) => (v > vals[b] ? i : b), 0);
  const runs: { a: number; b: number }[] = [];
  for (let i = 0, s = -1; i <= count; i++) {
    const on = i < count && vals[i] >= goal && !future(i);
    if (on && s < 0) s = i;
    if (!on && s >= 0) {
      runs.push({ a: s, b: i - 1 });
      s = -1;
    }
  }
  const longest = [...runs].sort((x, y) => y.b - y.a - (x.b - x.a) || x.a - y.a).slice(0, 3);
  const lastDay = Math.min(count - 1, Math.round((dataHi - lo) / DAY));
  const currentRun = runs.find((r) => r.b === lastDay || r.b === lastDay - 1);
  const [picked, setPicked] = useState<number | null>(variant === 'streaks' && longest.length ? 0 : null);
  const pickedRun = picked !== null ? longest[picked] : null;
  const runOf = (i: number) => runs.find((r) => i >= r.a && i <= r.b);
  const lenText = (r: { a: number; b: number }) => (r.b - r.a + 1 === 1 ? t.day : fill(t.days, { n: String(r.b - r.a + 1) }));

  // Week grid: column = week, row = weekday
  const offset = (dateOf(0).getUTCDay() - start + 7) % 7;
  const weeks = Math.ceil((count + offset) / 7);
  const colOf = (i: number) => Math.floor((i + offset) / 7);
  const rowOf = (i: number) => (i + offset) % 7;

  // Keyboard: in the week grid left and right is a week, in month calendars a day
  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? Math.min(lastDay, count - 1);
    const months = variant === 'months';
    const moves: Record<string, number> = months ? { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, PageUp: -30, PageDown: 30 } : { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1, PageUp: -30, PageDown: 30 };
    if (e.key === 'Escape') return setActive(null);
    let next: number;
    if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = lastDay;
    else if (moves[e.key] !== undefined) next = active === null ? cur : cur + moves[e.key];
    else return;
    e.preventDefault();
    setActive(Math.max(0, Math.min(count - 1, next)));
  };

  // Where the weeks do not fit (phones, several years on tablets) they scroll: start at the
  // latest week and keep the active day in view. The refs carry the geometry of the render.
  const cRef = useRef(12);
  const posRef = useRef<(i: number) => { x: number; y: number }>(() => ({ x: 0, y: 0 }));
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollLeft = el.scrollWidth;
    setScroll({ left: el.scrollLeft, max: el.scrollWidth - el.clientWidth });
  }, [phone, variant, width]);
  useEffect(() => {
    const el = scrollRef.current;
    if (!el || active === null || variant === 'months' || el.scrollWidth <= el.clientWidth) return;
    const cell = cRef.current;
    const x = posRef.current(active).x;
    if (x < el.scrollLeft + 8) el.scrollLeft = x - 24;
    else if (x + cell > el.scrollLeft + el.clientWidth - 8) el.scrollLeft = x + cell - el.clientWidth + 24;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active]);

  const live = active !== null ? `${long.format(dateOf(active))}, ${future(active) ? ct.noData : fmt(vals[active])}${runOf(active) && variant === 'streaks' ? `. ${fill(t.streakOf, { n: String(runOf(active)!.b - runOf(active)!.a + 1) })}` : ''}` : '';
  const auto = fill(t.summary, { total: fmt(total), active: String(activeDays), best: `${short.format(dateOf(bestI))} (${fmt(vals[bestI])})` });
  const table = <ChartTable caption={name} head={[t.date, valueLabel]} rows={vals.map((v, i) => (future(i) ? null : [long.format(dateOf(i)), fmt(v)])).filter((r): r is string[] => r !== null && (r[1] !== fmt(0) || variant === 'months'))} />;

  const fillOf = (i: number) => {
    if (future(i)) return 'transparent';
    const s = step(vals[i]);
    return s < 0 ? 'var(--kitch-empty)' : rampColor(s);
  };
  const dimOf = (i: number) => (pickedRun && (i < pickedRun.a || i > pickedRun.b) ? 0.28 : 1);

  /* The key with thresholds */
  const key = (
    <div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] text-muted-foreground" aria-hidden>
      <span className="me-1">{ct.less}</span>
      <span className="size-3 rounded-[3px]" style={{ background: 'var(--kitch-empty)' }} />
      {th.map((x, k) => (
        <span key={k} className="inline-flex items-center gap-1">
          <span className="size-3 rounded-[3px]" style={{ background: rampColor(Math.round((k / Math.max(1, th.length - 1)) * 4)) }} />
          <span className="tabular-nums">{`${fmt(x)}+`}</span>
        </span>
      ))}
      <span className="ms-1">{ct.more}</span>
    </div>
  );

  const statRow =
    stats && (variant === 'year' || variant === 'streaks') ? (
      <dl className="mb-5 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-4">
        {[
          { k: t.total, v: fmt(total) },
          { k: t.active, v: new Intl.NumberFormat(intl).format(activeDays) },
          { k: t.best, v: `${fmt(vals[bestI])}`, note: short.format(dateOf(bestI)) },
          variant === 'streaks' ? { k: t.current, v: currentRun ? lenText(currentRun) : t.none } : { k: t.longest, v: longest[0] ? lenText(longest[0]) : t.none },
        ].map((s, k) => (
          <div key={s.k} className={cn('bg-card px-3.5 py-3', anim && 'kitch-rise')} style={delay(80 + k * 60)}>
            <dt className="text-[12px] text-muted-foreground">{s.k}</dt>
            <dd className="mt-1 flex flex-wrap items-baseline gap-x-1.5 text-[17px] font-semibold tabular-nums text-foreground">
              {s.v}
              {s.note && <span className="text-[12px] font-normal text-muted-foreground">{s.note}</span>}
            </dd>
          </div>
        ))}
      </dl>
    ) : null;

  const surfaceBase = {
    label: name,
    summary: summary ?? auto,
    labels,
    live,
    table,
    onKeyDown,
    onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(lastDay),
    onBlur: () => setActive(null),
    onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
  };

  /* ---------------- months: small calendars ---------------- */
  if (variant === 'months') {
    const monthsList: { y: number; m: number }[] = [];
    for (let d = new Date(lo); d.getTime() <= hi; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) monthsList.push({ y: d.getUTCFullYear(), m: d.getUTCMonth() });
    const perRow = width >= 900 ? 4 : width >= 600 ? 3 : 2;
    const colW = (width - (perRow - 1) * 20) / perRow;
    const c = Math.floor((colW - 6 * GAP) / 7);
    const showNum = c >= 18;
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <ChartSurface {...surfaceBase}>
              <div className="grid gap-x-5 gap-y-6" style={{ gridTemplateColumns: `repeat(${perRow}, minmax(0, 1fr))` }}>
                {monthsList.map(({ y, m }, mi) => {
                  const first = Date.UTC(y, m, 1);
                  const nDays = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
                  const lead = (new Date(first).getUTCDay() - start + 7) % 7;
                  const sum = Array.from({ length: nDays }, (_, d) => {
                    const i = Math.round((first + d * DAY - lo) / DAY);
                    return i >= 0 && i < count ? vals[i] : 0;
                  }).reduce((a, b) => a + b, 0);
                  const rowsN = Math.ceil((lead + nDays) / 7);
                  return (
                    <section key={`${y}-${m}`} className={cn(anim && 'kitch-rise')} style={delay(60 + mi * 45)}>
                      <h4 className="mb-2 flex items-baseline justify-between gap-2 text-[13px] font-medium text-foreground">
                        <span className="truncate">{monthLong.format(new Date(first))}</span>
                        <span className="text-[12px] font-normal tabular-nums text-muted-foreground">{fmt(sum)}</span>
                      </h4>
                      <svg width="100%" height={14 + rowsN * (c + GAP)} viewBox={`0 0 ${round(7 * (c + GAP) - GAP)} ${14 + rowsN * (c + GAP)}`} preserveAspectRatio="xMinYMin meet" aria-hidden className="block overflow-visible">
                        {Array.from({ length: 7 }, (_, r) => (
                          <text key={r} x={round(r * (c + GAP) + c / 2)} y={9} textAnchor="middle" className="text-[10px]" fill="var(--muted-foreground)">
                            {wdF.format(new Date(Date.UTC(2026, 0, 4 + ((r + start) % 7))))}
                          </text>
                        ))}
                        {Array.from({ length: nDays }, (_, d) => {
                          const i = Math.round((first + d * DAY - lo) / DAY);
                          if (i < 0 || i >= count) return null;
                          const col = (lead + d) % 7;
                          const row = Math.floor((lead + d) / 7);
                          const x = round(col * (c + GAP));
                          const yy = round(14 + row * (c + GAP));
                          const s = future(i) ? -2 : step(vals[i]);
                          const ink = s >= 3 ? 'var(--primary-foreground)' : s === 2 ? 'var(--foreground)' : 'var(--muted-foreground)';
                          return (
                            <g key={d} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className="cursor-pointer">
                              <rect x={x} y={yy} width={c} height={c} rx={Math.min(5, c / 4)} fill={fillOf(i)} stroke={future(i) ? 'var(--kitch-grid)' : 'none'} strokeDasharray={future(i) ? '2 2' : undefined} className={anim ? 'kitch-cell' : undefined} style={delay(120 + mi * 45 + d * 6)} />
                              {showNum && (
                                <text x={round(x + c / 2)} y={round(yy + c / 2)} dy="0.34em" textAnchor="middle" className="pointer-events-none text-[10px] tabular-nums" fill={ink}>
                                  {d + 1}
                                </text>
                              )}
                              {active === i && <rect x={x - 1.5} y={yy - 1.5} width={c + 3} height={c + 3} rx={Math.min(6, c / 4 + 1)} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
                            </g>
                          );
                        })}
                      </svg>
                    </section>
                  );
                })}
              </div>
              {shown !== null && (
                <p aria-hidden className={cn('mt-4 flex min-h-5 flex-wrap items-center gap-x-2 text-[12px] transition-opacity duration-200', open ? 'opacity-100' : 'opacity-0')}>
                  <span className="text-muted-foreground">{long.format(dateOf(shown))}</span>
                  <span className="font-semibold tabular-nums text-foreground">{future(shown) ? ct.noData : `${fmt(vals[shown])} ${valueLabel}`}</span>
                </p>
              )}
            </ChartSurface>
          </div>
          <div className="mt-4 flex justify-end">{key}</div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- year, years, streaks: the week grid ---------------- */
  const yearsList = variant === 'years' ? [...new Set(Array.from({ length: count }, (_, i) => dateOf(i).getUTCFullYear()))] : [];
  const leftW = textWidth(wdShort.format(new Date(Date.UTC(2026, 0, 7))), 11) + 10;
  const yearW = variant === 'years' ? 40 : 0;
  const totalW = variant === 'years' && !phone ? 64 : 0;
  // Years: each row is one calendar year, the same 53 columns
  const yearWeeks = 54;
  const gridCols = variant === 'years' ? yearWeeks : weeks;
  const fitCell = Math.floor((width - leftW - yearW - totalW + GAP) / gridCols) - GAP;
  const c = phone ? 12 : Math.max(8, Math.min(variant === 'years' ? 14 : 18, fitCell));
  cRef.current = c;
  const gridW = gridCols * (c + GAP) - GAP;
  const headH = 18;
  const blockH = 7 * (c + GAP) - GAP;
  const yearGap = 18;
  const gridH = variant === 'years' ? headH + yearsList.length * (blockH + yearGap) - yearGap : headH + blockH + (variant === 'streaks' ? 26 : 0);

  // Cell position
  const yearRow = (i: number) => (variant === 'years' ? yearsList.indexOf(dateOf(i).getUTCFullYear()) : 0);
  const posOf = (i: number) => {
    if (variant === 'years') {
      const d = dateOf(i);
      const y0 = Date.UTC(d.getUTCFullYear(), 0, 1);
      const off = (new Date(y0).getUTCDay() - start + 7) % 7;
      const k = Math.round((d.getTime() - y0) / DAY);
      return { x: Math.floor((k + off) / 7) * (c + GAP), y: headH + yearRow(i) * (blockH + yearGap) + ((k + off) % 7) * (c + GAP) };
    }
    return { x: colOf(i) * (c + GAP), y: headH + rowOf(i) * (c + GAP) };
  };
  posRef.current = posOf;
  // Month marks along the top
  const marks: { x: number; text: string }[] = [];
  for (let d = new Date(lo); d.getTime() <= hi; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) {
    if (variant === 'years' && d.getUTCFullYear() !== yearsList[0]) break;
    const i = Math.round((d.getTime() - lo) / DAY);
    const p = posOf(Math.max(0, i));
    const x = rowOf(Math.max(0, i)) === 0 || variant === 'years' ? p.x : p.x + c + GAP;
    if (!marks.length || x - marks[marks.length - 1].x >= textWidth(marks[marks.length - 1].text, 11) + 6) marks.push({ x, text: monthF.format(d) });
  }

  const pick = (e: React.PointerEvent<HTMLDivElement>) => {
    const b = e.currentTarget.getBoundingClientRect();
    const px = e.clientX - b.left;
    const py = e.clientY - b.top;
    const col = Math.floor(px / (c + GAP));
    if (variant === 'years') {
      const yr = Math.floor((py - headH) / (blockH + yearGap));
      const row = Math.floor((py - headH - yr * (blockH + yearGap)) / (c + GAP));
      const year = yearsList[yr];
      if (year === undefined || row < 0 || row > 6) return setActive(null);
      const y0 = Date.UTC(year, 0, 1);
      const off = (new Date(y0).getUTCDay() - start + 7) % 7;
      const i = Math.round((y0 + (col * 7 + row - off) * DAY - lo) / DAY);
      return setActive(i >= 0 && i < count && dateOf(i).getUTCFullYear() === year ? i : null);
    }
    const row = Math.floor((py - headH) / (c + GAP));
    const i = col * 7 + row - offset;
    setActive(row >= 0 && row < 7 && i >= 0 && i < count ? i : null);
  };

  const sp = shown !== null ? posOf(shown) : { x: 0, y: 0 };
  const tipX = round(leftW + yearW + sp.x - scroll.left + c / 2);

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        {statRow}
        {variant === 'streaks' && (
          <div className="mb-4">
            <p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.streaks}</p>
            {longest.length ? (
              <ul className="flex flex-wrap gap-2">
                {longest.map((r, k) => (
                  <li key={r.a}>
                    <button
                      type="button"
                      aria-pressed={picked === k}
                      onClick={() => setPicked(picked === k ? null : k)}
                      className={cn(
                        'inline-flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13px] transition-[background-color,border-color,color] duration-200 max-sm:min-h-11',
                        picked === k ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
                      )}
                    >
                      <span className="font-semibold tabular-nums">{lenText(r)}</span>
                      <span className="tabular-nums">{fill(t.span, { a: short.format(dateOf(r.a)), b: short.format(dateOf(r.b)) })}</span>
                    </button>
                  </li>
                ))}
              </ul>
            ) : (
              <p className="text-[13px] text-muted-foreground">{t.none}</p>
            )}
          </div>
        )}
        <div ref={wrapRef}>
          <ChartSurface {...surfaceBase}>
            <div className="flex">
              {/* Weekdays (and years) stay in place while the weeks scroll */}
              <svg width={leftW + yearW} height={gridH} aria-hidden className="block shrink-0 overflow-visible">
                {(variant === 'years' ? yearsList : [0]).map((yr, yi) => (
                  <g key={yr}>
                    {variant === 'years' && (
                      <text x={0} y={headH + yi * (blockH + yearGap) + blockH / 2} dy="0.34em" className="text-[12px] font-semibold tabular-nums" fill="var(--foreground)">
                        {yr}
                      </text>
                    )}
                    {Array.from({ length: 7 }, (_, r) =>
                      (r + start) % 7 === 1 || (r + start) % 7 === 3 || (r + start) % 7 === 5 ? (
                        <text key={r} x={yearW} y={round(headH + yi * (blockH + yearGap) + r * (c + GAP) + c / 2)} dy="0.34em" className="text-[11px]" fill="var(--muted-foreground)">
                          {wdShort.format(new Date(Date.UTC(2026, 0, 4 + ((r + start) % 7))))}
                        </text>
                      ) : null,
                    )}
                  </g>
                ))}
              </svg>
              <div
                ref={scrollRef}
                onScroll={(e) => setScroll({ left: e.currentTarget.scrollLeft, max: e.currentTarget.scrollWidth - e.currentTarget.clientWidth })}
                className={cn('kit-no-scrollbar relative min-w-0 flex-1 overflow-x-auto', scroll.max > 4 && (scroll.left < 4 ? 'kit-fade-end' : scroll.left >= scroll.max - 4 ? 'kit-fade-start' : 'kit-fade-x'))}
              >
                <div className="relative" style={{ width: gridW }} onPointerMove={pick} onPointerDown={pick}>
                  <svg width={gridW} height={gridH} viewBox={`0 0 ${gridW} ${gridH}`} aria-hidden className="block overflow-visible">
                    {marks.map((m) => (
                      <text key={m.x} x={m.x} y={11} className="text-[11px]" fill="var(--muted-foreground)">
                        {m.text}
                      </text>
                    ))}
                    {vals.map((_, i) => {
                      const p = posOf(i);
                      const fut = future(i);
                      return (
                        <rect
                          key={i}
                          x={p.x}
                          y={p.y}
                          width={c}
                          height={c}
                          rx={c >= 14 ? 4 : 3}
                          fill={fillOf(i)}
                          stroke={fut ? 'var(--kitch-grid)' : 'none'}
                          opacity={dimOf(i)}
                          className={cn('transition-opacity duration-300 motion-reduce:transition-none', anim && 'kitch-cell')}
                          style={delay((p.x / Math.max(1, gridW)) * 700 + (p.y / Math.max(1, gridH)) * 120)}
                        />
                      );
                    })}
                    {/* The picked streak: a bracket under its weeks, the other days step back */}
                    {pickedRun &&
                      (() => {
                        const x0 = posOf(pickedRun.a).x;
                        const x1 = posOf(pickedRun.b).x + c;
                        const y = headH + blockH + 8;
                        const text = lenText(pickedRun);
                        const tx = Math.min(Math.max((x0 + x1) / 2, textWidth(text, 11) / 2), gridW - textWidth(text, 11) / 2);
                        return (
                          <g className={anim ? 'kitch-fade' : undefined} style={delay(800)}>
                            <path d={`M${x0} ${y - 4}V${y}H${x1}V${y - 4}`} fill="none" stroke="var(--primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
                            <text x={round(tx)} y={y + 13} textAnchor="middle" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)">
                              {text}
                            </text>
                          </g>
                        );
                      })()}
                    {shown !== null && <rect x={-1.5} y={-1.5} width={c + 3} height={c + 3} rx={4} fill="none" stroke="var(--foreground)" strokeWidth="1.5" className="kitch-glide pointer-events-none" data-jump={jump ? '' : undefined} style={{ transform: `translate(${sp.x}px, ${sp.y}px)`, opacity: open ? 1 : 0 }} />}
                  </svg>
                </div>
              </div>
              {variant === 'years' && !phone && (
                <svg width={totalW} height={gridH} aria-hidden className="block shrink-0 overflow-visible">
                  {yearsList.map((yr, yi) => {
                    const sum = vals.reduce((a, v, i) => (dateOf(i).getUTCFullYear() === yr ? a + v : a), 0);
                    return (
                      <text key={yr} x={totalW} y={headH + yi * (blockH + yearGap) + blockH / 2} dy="0.34em" textAnchor="end" className="text-[13px] font-semibold tabular-nums" fill="var(--foreground)">
                        {fmt(sum)}
                      </text>
                    );
                  })}
                </svg>
              )}
            </div>
            {shown !== null && <ChartTooltip open={open} jump={jump} x={tipX} y={Math.max(0, sp.y - 8)} width={width} offset={c / 2 + 8} title={long.format(dateOf(shown))} rows={[{ key: 'v', label: valueLabel, value: future(shown) ? ct.noData : fmt(vals[shown]) }, ...(variant === 'streaks' && runOf(shown) ? [{ key: 's', label: t.streak, value: lenText(runOf(shown)!) }] : [])]} />}
          </ChartSurface>
        </div>
        <div className="mt-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
          {variant === 'years' && phone ? (
            <p className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
              {yearsList.map((yr) => (
                <span key={yr} className="tabular-nums">
                  <span className="font-medium text-foreground">{yr}</span> {fmt(vals.reduce((a, v, i) => (dateOf(i).getUTCFullYear() === yr ? a + v : a), 0))}
                </span>
              ))}
            </p>
          ) : (
            <span />
          )}
          {key}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Kalender-Heatmap?

Mit aiw add @ai-website/calendar-heatmap. Der Befehl legt die Datei components/ui/calendar-heatmap.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Kalender-Heatmap?

4 Varianten, Jahr, Monatskalender, Mehrere Jahre und Serien. Du wählst sie mit der Prop variant, zum Beispiel variant="months".

Kann ich die Texte von Kalender-Heatmap übersetzen?

Ja. Kalender-Heatmap bringt 14 eigene Texte auf Deutsch und Englisch mit, etwa „Gesamt“, „Aktive Tage“ und „Bester Tag“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kalender-Heatmap einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kalender-Heatmap mit aiw_components und installieren sie mit aiw_add @ai-website/calendar-heatmap. Nenn im Prompt die Variante, etwa variant="year", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert