Zum Inhalt springen
UI Kit
Accent

Zeiterfassung UI

Zeiterfassung mit Live-Timer, Wochenzettel wie eine Tabelle, Einträgen je Tag zum Bearbeiten und Abrechnen, Wochenübersicht mit Diagrammen und Fokus-Timer.

5 VariantsEine von 4 Components in Aufgaben und Planung

Jede Stunde dort gezählt, wo sie entsteht

Zeiterfassung funktioniert, wenn sie Sekunden dauert, starten, notieren, stoppen. Die fünf Screens hier decken den laufenden Timer, die Woche auf einem Blatt, die Einträge zum Abrechnen, den Rückblick auf die Woche und konzentriertes Arbeiten in Runden ab.

Der „Timer“ läuft mit Notiz, Projekt, Tätigkeit und Abrechenbar-Schalter, listet die Einträge von heute zum Fortsetzen oder Löschen und stellt die Woche gegen ihr Ziel. Der „Wochenzettel“ legt Projekte über die Tage mit Summen je Zeile, Tag und Woche, die Pfeiltasten wandern wie in einer Tabelle, Telefone bekommen Stepper. „Einträge“ gruppiert nach Tag mit Stunden und Betrag oben, filtert nach Projekt und abrechenbar, bearbeitet direkt und löscht mit Rückgängig. Die „Wochenübersicht“ stellt Stunden gegen das Ziel, mit abrechenbarem Anteil, Betrag und Tagesschnitt, gestapelten Balken je Tag, der Verteilung nach Projekt und der Vorwoche. Der „Fokus-Timer“ dreht Runden und Pausen auf einem Ring mit Minutenskala und bucht jede fertige Runde.

Setz den Timer dorthin, wo Menschen ohnehin sind, in den Kopf oder die Sidebar deiner App, eine eigene Seite gerät in Vergessenheit. Zeig abrechenbare Zeit und Betrag zusammen, dann muss niemand rechnen. Wird der Wochenzettel einmal pro Woche eingereicht, bleiben Korrekturen klein.

Variants

TimerNeu in 1.2

Ein laufender Timer mit Notiz, Projekt, Tätigkeit und Abrechenbar-Schalter, die Einträge von heute zum Fortsetzen oder Löschen und die Woche gegen ihr Ziel.

WochenzettelNeu in 1.2

Projekte mal Tage mit Summen je Zeile, Tag und Woche, Pfeiltasten wie in einer Tabelle, Wochenwechsel, Einreichen und Stepper am Telefon.

EinträgeNeu in 1.2

Einträge nach Tag mit Summen, Stunden und Betrag oben, Filter nach Projekt und abrechenbar, Bearbeiten an Ort und Stelle und Löschen mit Rückgängig.

WochenübersichtNeu in 1.2

Stunden gegen das Ziel, abrechenbarer Anteil, Betrag und Tagesschnitt, gestapelte Balken je Tag, die Verteilung nach Projekt und die Vorwoche zum Vergleich.

Fokus-TimerNeu in 1.2

Fokusrunden und Pausen auf einem Ring mit Minutenskala, Starten, Pausieren, Zurücksetzen und Überspringen, woran du arbeitest, und jede fertige Runde gebucht.

Details

Was Zeiterfassung mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/time-tracker.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 78 eigene Texte
Studio
Im Code anpassen
In Sammlungen
SaaS-App-Screens

Props

Die Schnittstelle TimeTrackerProps aus components/ui/time-tracker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    TimeTrackerVariant

    timertimesheetentriesweek-summaryfocus

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlePflicht
    string
  • subtitleoptional
    string
  • projectsoptional
    TimeProject[]
  • tasksoptional
    string[]

    Kinds of work to pick from

  • entriesoptional
    TimeEntry[]
  • runningoptional
    { project: string; task?: string; note?: string; start: string } | null

    timer: a timer that already runs, started today at HH:MM

  • nowoptional
    string

    YYYY-MM-DDTHH:MM that counts as now

  • rowsoptional
    TimesheetRow[]

    timesheet: the rows of the current week

  • goaloptional
    number

    Hours a week

  • dailyGoaloptional
    number

    Hours a day

  • currencyoptional
    string

    Currency of the rates, e.g. EUR

  • focusoptional
    { work: number; short: number; long: number; rounds: number }

    focus: minutes of a focus round, a short and a long break

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on start, stop, add, update, delete and submit. Without it the block simulates the request.

Eingebaute Texte

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

Alle 78 Texte anzeigen
noteEnglisch What are you working on?Deutsch Woran arbeitest du?
projectEnglisch ProjectDeutsch Projekt
taskEnglisch ActivityDeutsch Tätigkeit
billableEnglisch BillableDeutsch Abrechenbar
notBillableEnglisch Not billableDeutsch Nicht abrechenbar
startEnglisch Start timerDeutsch Timer starten
stopEnglisch Stop timerDeutsch Timer stoppen
runningEnglisch Running since {t}Deutsch Läuft seit {t}
idleEnglisch Ready when you areDeutsch Bereit, wenn du es bist
startedEnglisch Timer started for {p}.Deutsch Timer für {p} gestartet.
stoppedEnglisch {d} logged on {p}.Deutsch {d} auf {p} gebucht.
todayEnglisch TodayDeutsch Heute
thisWeekEnglisch This weekDeutsch Diese Woche
weekOfEnglisch {h} of {g} h this weekDeutsch {h} von {g} Std. diese Woche
resumeEnglisch Continue {p}Deutsch {p} fortsetzen
removeEnglisch Delete entry {p}Deutsch Eintrag {p} löschen
removedEnglisch Entry deleted.Deutsch Eintrag gelöscht.
undoEnglisch UndoDeutsch Rückgängig
noEntriesEnglisch No time logged yet. Start the timer and it shows up here.Deutsch Noch keine Zeit erfasst. Starte den Timer, dann steht sie hier.
totalEnglisch TotalDeutsch Summe
dayEnglisch DayDeutsch Tag
weekEnglisch WeekDeutsch Woche
prevWeekEnglisch Previous weekDeutsch Vorige Woche
nextWeekEnglisch Next weekDeutsch Nächste Woche
cellEnglisch {p} on {d}Deutsch {p} am {d}
rowTotalEnglisch Total for {p}Deutsch Summe für {p}
addRowEnglisch Add rowDeutsch Zeile hinzufügen
removeRowEnglisch Remove row {p}Deutsch Zeile {p} entfernen
submitEnglisch Submit weekDeutsch Woche einreichen
submittingEnglisch Submitting…Deutsch Wird eingereicht…
submittedEnglisch Submitted for approvalDeutsch Zur Freigabe eingereicht
submittedSayEnglisch Week submitted. Your manager gets a note.Deutsch Woche eingereicht. Deine Leitung bekommt Bescheid.
copyLastEnglisch Copy rows from last weekDeutsch Zeilen der Vorwoche übernehmen
emptyWeekEnglisch Nothing logged in this week yet.Deutsch In dieser Woche ist noch nichts erfasst.
goalDayEnglisch {h} of {g} hDeutsch {h} von {g} Std.
moreEnglisch Add 15 minutes to {p}Deutsch 15 Minuten mehr für {p}
lessEnglisch Take 15 minutes off {p}Deutsch 15 Minuten weniger für {p}
hoursInEnglisch HoursDeutsch Stunden
allProjectsEnglisch All projectsDeutsch Alle Projekte
onlyBillableEnglisch Billable onlyDeutsch Nur abrechenbar
editEnglisch Edit entry {p}Deutsch Eintrag {p} bearbeiten
saveEnglisch SaveDeutsch Speichern
cancelEnglisch CancelDeutsch Abbrechen
savedEnglisch Entry saved.Deutsch Eintrag gespeichert.
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch Bis
amountEnglisch Billable amountDeutsch Abrechenbarer Betrag
billableShareEnglisch Billable shareDeutsch Abrechenbarer Anteil
totalHoursEnglisch Hours loggedDeutsch Erfasste Stunden
avgDayEnglisch Average a dayDeutsch Schnitt am Tag
toggleBillableEnglisch Billable, {p}Deutsch Abrechenbar, {p}
byDayEnglisch Hours by dayDeutsch Stunden je Tag
byProjectEnglisch By projectDeutsch Nach Projekt
shareEnglisch ShareDeutsch Anteil
fromProjectsEnglisch From {n} projectsDeutsch Aus {n} Projekten
goalEnglisch Weekly goalDeutsch Wochenziel
goalLeftEnglisch {h} h to goDeutsch noch {h} Std.
goalMetEnglisch Goal reachedDeutsch Ziel erreicht
lastWeekEnglisch Last weekDeutsch Vorwoche
focusEnglisch FocusDeutsch Fokus
shortBreakEnglisch Short breakDeutsch Kurze Pause
longBreakEnglisch Long breakDeutsch Lange Pause
modeEnglisch ModeDeutsch Modus
playEnglisch StartDeutsch Starten
pauseEnglisch PauseDeutsch Pausieren
resetEnglisch ResetDeutsch Zurücksetzen
skipEnglisch Skip to nextDeutsch Weiter zum Nächsten
roundEnglisch Round {n} of {m}Deutsch Runde {n} von {m}
sessionsEnglisch Focus sessions todayDeutsch Fokusrunden heute
focusTotalEnglisch {t} of focus todayDeutsch {t} Fokus heute
focusDoneEnglisch Focus round done. Time for a break.Deutsch Fokusrunde geschafft. Zeit für eine Pause.
breakDoneEnglisch Break over. Ready for the next round.Deutsch Pause vorbei. Bereit für die nächste Runde.
noSessionsEnglisch Your finished rounds land here.Deutsch Hier landen deine fertigen Runden.
focusOnEnglisch Working onDeutsch Woran
timeLeftEnglisch {t} leftDeutsch noch {t}
hEnglisch {h} hDeutsch {h} Std.
mEnglisch {m} minDeutsch {m} Min.
hmEnglisch {h} h {m} minDeutsch {h} Std. {m} Min.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Zeiterfassung reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen label, radio, radiogroup, status und timer.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 1.505 Zeilen
components/ui/time-tracker.tsx
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, formatPercent, formatTime, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { BarChart } from '@/components/ui/bar-chart';
import { Button } from '@/components/ui/button';
import { DonutChart } from '@/components/ui/donut-chart';
import { Icon } from '@/components/ui/icon';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type TimeTrackerVariant = 'timer' | 'timesheet' | 'entries' | 'week-summary' | 'focus';

export interface TimeProject {
  id: string;
  name: string;
  client?: string;
  /** Time on this project is billable by default */
  billable?: boolean;
  /** Hourly rate for billable time */
  rate?: number;
}

export interface TimeEntry {
  id: string;
  /** Project id */
  project: string;
  /** What kind of work, e.g. Design */
  task?: string;
  note?: string;
  /** YYYY-MM-DD */
  date: string;
  /** HH:MM */
  start: string;
  /** HH:MM */
  end: string;
  billable?: boolean;
}

export interface TimesheetRow {
  id: string;
  /** Project id */
  project: string;
  task?: string;
  /** Hours per day, Monday first */
  hours: number[];
}

export interface TimeTrackerProps {
  variant?: TimeTrackerVariant;
  /** The app frame. Leave it out (or null) for a standalone screen. */
  sidebar?: Omit<SidebarProps, 'children'> | null;
  title: string;
  subtitle?: string;
  projects?: TimeProject[];
  /** Kinds of work to pick from */
  tasks?: string[];
  entries?: TimeEntry[];
  /** timer: a timer that already runs, started today at HH:MM */
  running?: { project: string; task?: string; note?: string; start: string } | null;
  /** YYYY-MM-DDTHH:MM that counts as now */
  now?: string;
  /** timesheet: the rows of the current week */
  rows?: TimesheetRow[];
  /** Hours a week */
  goal?: number;
  /** Hours a day */
  dailyGoal?: number;
  /** Currency of the rates, e.g. EUR */
  currency?: string;
  /** focus: minutes of a focus round, a short and a long break */
  focus?: { work: number; short: number; long: number; rounds: number };
  /** Called on start, stop, add, update, delete and submit. Without it the block simulates the request. */
  onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    note: 'What are you working on?',
    project: 'Project',
    task: 'Activity',
    billable: 'Billable',
    notBillable: 'Not billable',
    start: 'Start timer',
    stop: 'Stop timer',
    running: 'Running since {t}',
    idle: 'Ready when you are',
    started: 'Timer started for {p}.',
    stopped: '{d} logged on {p}.',
    today: 'Today',
    thisWeek: 'This week',
    weekOf: '{h} of {g} h this week',
    resume: 'Continue {p}',
    remove: 'Delete entry {p}',
    removed: 'Entry deleted.',
    undo: 'Undo',
    noEntries: 'No time logged yet. Start the timer and it shows up here.',
    total: 'Total',
    day: 'Day',
    week: 'Week',
    prevWeek: 'Previous week',
    nextWeek: 'Next week',
    cell: '{p} on {d}',
    rowTotal: 'Total for {p}',
    addRow: 'Add row',
    removeRow: 'Remove row {p}',
    submit: 'Submit week',
    submitting: 'Submitting…',
    submitted: 'Submitted for approval',
    submittedSay: 'Week submitted. Your manager gets a note.',
    copyLast: 'Copy rows from last week',
    emptyWeek: 'Nothing logged in this week yet.',
    goalDay: '{h} of {g} h',
    more: 'Add 15 minutes to {p}',
    less: 'Take 15 minutes off {p}',
    hoursIn: 'Hours',
    allProjects: 'All projects',
    onlyBillable: 'Billable only',
    edit: 'Edit entry {p}',
    save: 'Save',
    cancel: 'Cancel',
    saved: 'Entry saved.',
    from: 'From',
    to: 'To',
    amount: 'Billable amount',
    billableShare: 'Billable share',
    totalHours: 'Hours logged',
    avgDay: 'Average a day',
    toggleBillable: 'Billable, {p}',
    byDay: 'Hours by day',
    byProject: 'By project',
    share: 'Share',
    fromProjects: 'From {n} projects',
    goal: 'Weekly goal',
    goalLeft: '{h} h to go',
    goalMet: 'Goal reached',
    lastWeek: 'Last week',
    focus: 'Focus',
    shortBreak: 'Short break',
    longBreak: 'Long break',
    mode: 'Mode',
    play: 'Start',
    pause: 'Pause',
    reset: 'Reset',
    skip: 'Skip to next',
    round: 'Round {n} of {m}',
    sessions: 'Focus sessions today',
    focusTotal: '{t} of focus today',
    focusDone: 'Focus round done. Time for a break.',
    breakDone: 'Break over. Ready for the next round.',
    noSessions: 'Your finished rounds land here.',
    focusOn: 'Working on',
    timeLeft: '{t} left',
    h: '{h} h',
    m: '{m} min',
    hm: '{h} h {m} min',
  },
  de: {
    note: 'Woran arbeitest du?',
    project: 'Projekt',
    task: 'Tätigkeit',
    billable: 'Abrechenbar',
    notBillable: 'Nicht abrechenbar',
    start: 'Timer starten',
    stop: 'Timer stoppen',
    running: 'Läuft seit {t}',
    idle: 'Bereit, wenn du es bist',
    started: 'Timer für {p} gestartet.',
    stopped: '{d} auf {p} gebucht.',
    today: 'Heute',
    thisWeek: 'Diese Woche',
    weekOf: '{h} von {g} Std. diese Woche',
    resume: '{p} fortsetzen',
    remove: 'Eintrag {p} löschen',
    removed: 'Eintrag gelöscht.',
    undo: 'Rückgängig',
    noEntries: 'Noch keine Zeit erfasst. Starte den Timer, dann steht sie hier.',
    total: 'Summe',
    day: 'Tag',
    week: 'Woche',
    prevWeek: 'Vorige Woche',
    nextWeek: 'Nächste Woche',
    cell: '{p} am {d}',
    rowTotal: 'Summe für {p}',
    addRow: 'Zeile hinzufügen',
    removeRow: 'Zeile {p} entfernen',
    submit: 'Woche einreichen',
    submitting: 'Wird eingereicht…',
    submitted: 'Zur Freigabe eingereicht',
    submittedSay: 'Woche eingereicht. Deine Leitung bekommt Bescheid.',
    copyLast: 'Zeilen der Vorwoche übernehmen',
    emptyWeek: 'In dieser Woche ist noch nichts erfasst.',
    goalDay: '{h} von {g} Std.',
    more: '15 Minuten mehr für {p}',
    less: '15 Minuten weniger für {p}',
    hoursIn: 'Stunden',
    allProjects: 'Alle Projekte',
    onlyBillable: 'Nur abrechenbar',
    edit: 'Eintrag {p} bearbeiten',
    save: 'Speichern',
    cancel: 'Abbrechen',
    saved: 'Eintrag gespeichert.',
    from: 'Von',
    to: 'Bis',
    amount: 'Abrechenbarer Betrag',
    billableShare: 'Abrechenbarer Anteil',
    totalHours: 'Erfasste Stunden',
    avgDay: 'Schnitt am Tag',
    toggleBillable: 'Abrechenbar, {p}',
    byDay: 'Stunden je Tag',
    byProject: 'Nach Projekt',
    share: 'Anteil',
    fromProjects: 'Aus {n} Projekten',
    goal: 'Wochenziel',
    goalLeft: 'noch {h} Std.',
    goalMet: 'Ziel erreicht',
    lastWeek: 'Vorwoche',
    focus: 'Fokus',
    shortBreak: 'Kurze Pause',
    longBreak: 'Lange Pause',
    mode: 'Modus',
    play: 'Starten',
    pause: 'Pausieren',
    reset: 'Zurücksetzen',
    skip: 'Weiter zum Nächsten',
    round: 'Runde {n} von {m}',
    sessions: 'Fokusrunden heute',
    focusTotal: '{t} Fokus heute',
    focusDone: 'Fokusrunde geschafft. Zeit für eine Pause.',
    breakDone: 'Pause vorbei. Bereit für die nächste Runde.',
    noSessions: 'Hier landen deine fertigen Runden.',
    focusOn: 'Woran',
    timeLeft: 'noch {t}',
    h: '{h} Std.',
    m: '{m} Min.',
    hm: '{h} Std. {m} Min.',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion                                                              */
/* ------------------------------------------------------------------ */

const CSS =
  '@keyframes ttk-up{from{opacity:0;transform:translateY(10px)}}@keyframes ttk-grow{from{transform:scaleX(0)}}@keyframes ttk-pulse{0%{transform:scale(1);opacity:.55}80%,100%{transform:scale(2.6);opacity:0}}@keyframes ttk-fly{from{opacity:0;transform:translateY(-12px) scale(.98)}}@keyframes ttk-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}' +
  '.ttk-grow{transform-origin:left}' +
  '@media (prefers-reduced-motion:no-preference){[data-ttk=play] .ttk-up{animation:ttk-up .55s var(--ease-kit) var(--ttk-d,0ms) both}[data-ttk=wait] .ttk-up{opacity:0}[data-ttk=play] .ttk-grow{animation:ttk-grow 1s var(--ease-kit) .2s both}.ttk-pulse{animation:ttk-pulse 1.8s var(--ease-kit) infinite}.ttk-fly{animation:ttk-fly .5s var(--ease-kit) both}.ttk-in{animation:ttk-up .4s var(--ease-kit) both}.ttk-flash{animation:ttk-flash 1.6s ease-out}}';

function Style() {
  return (
    <style href="kitb5-time-tracker" precedence="default">
      {CSS}
    </style>
  );
}

function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(([e]) => {
      if (!e?.isIntersecting) return;
      setState('play');
      io.disconnect();
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-ttk': state }] as const;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const PALETTE = ['var(--primary)', 'color-mix(in oklab, var(--primary) 40%, var(--foreground))', 'var(--warning)', 'var(--success)', 'color-mix(in oklab, var(--primary) 45%, var(--background))', 'var(--subtle-foreground)'];
const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const isoOf = (n: number) => new Date(n * dayMs).toISOString().slice(0, 10);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tagOf(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));
const toMin = (hhmm: string) => {
  const [h, m] = hhmm.split(':').map(Number);
  return (h || 0) * 60 + (m || 0);
};
const hhmm = (min: number) => `${String(Math.floor(min / 60) % 24).padStart(2, '0')}:${String(Math.round(min % 60)).padStart(2, '0')}`;
const mondayOf = (iso: string) => {
  const d = dayNum(iso);
  return isoOf(d - ((new Date(d * dayMs).getUTCDay() + 6) % 7));
};
/** 1:30 style, the way time sheets show hours */
const clock = (min: number) => `${Math.floor(min / 60)}:${String(Math.round(min % 60)).padStart(2, '0')}`;
const stopwatch = (sec: number) => `${String(Math.floor(sec / 3600)).padStart(2, '0')}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
const minutesOf = (e: TimeEntry) => Math.max(0, toMin(e.end) - toMin(e.start));

function dur(min: number, t: Texts) {
  const h = Math.floor(min / 60);
  const m = Math.round(min % 60);
  return h && m ? fill(t.hm, { h, m }) : h ? fill(t.h, { h }) : fill(t.m, { m });
}

const field =
  'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-30 sm:size-8', ring);

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
      <div className="min-w-0">
        {subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
        <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
      </div>
      {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
    </div>
  );
}

function Main({ children, className }: { children: React.ReactNode; className?: string }) {
  return <main className={cn('mx-auto w-full min-w-0 max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}

function Card({ title, action, children, className, bodyClassName, style }: { title?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; style?: React.CSSProperties }) {
  return (
    <section style={style} className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex min-h-13 shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
          {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,var(--background))] p-1', className)}>
      {options.map((o, i) => (
        <button
          key={o.value}
          type="button"
          role="radio"
          aria-checked={value === o.value}
          tabIndex={value === o.value ? 0 : -1}
          onClick={() => onChange(o.value)}
          onKeyDown={(e) => onKey(e, i)}
          className={cn('relative inline-flex h-11 flex-1 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[color,background-color,box-shadow] duration-200 sm:h-8', ring, value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground')}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

function Pick({ label, value, onChange, children, className, dot }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; dot?: string }) {
  return (
    <span className={cn('relative block min-w-0', className)}>
      {dot && <span aria-hidden className="pointer-events-none absolute start-3 top-1/2 size-2.5 -translate-y-1/2 rounded-full" style={{ background: dot }} />}
      <select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8', dot && 'ps-8')}>
        {children}
      </select>
      <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
    </span>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
  if (!text) return null;
  return (
    <div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
      <div key={text} className="ttk-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
        <Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
        <span className="min-w-0 flex-1 text-pretty">{text}</span>
        {onUndo && (
          <button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
            {t.undo}
          </button>
        )}
      </div>
    </div>
  );
}

function useToast() {
  const [toast, setToast] = useState<{ text: string; undo?: () => void } | null>(null);
  const timer = useRef(0);
  const show = (text: string, undo?: () => void) => {
    setToast({ text, undo });
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setToast(null), 5000);
  };
  return [toast, show, () => setToast(null)] as const;
}

function Bar({ value, className, tone = 'bg-primary', label }: { value: number; className?: string; tone?: string; label?: string }) {
  return (
    <span role={label ? 'meter' : undefined} aria-label={label} aria-valuenow={label ? Math.round(value) : undefined} aria-valuemin={label ? 0 : undefined} aria-valuemax={label ? 100 : undefined} aria-hidden={label ? undefined : true} className={cn('relative block h-2 overflow-hidden rounded-full bg-track', className)}>
      <span className={cn('ttk-grow absolute inset-y-0 start-0 rounded-full transition-[width] duration-700 ease-[var(--ease-kit)]', value >= 100 ? 'bg-success' : tone)} style={{ width: `${round(Math.max(0, Math.min(100, value)), 1)}%` }} />
    </span>
  );
}

function Dot({ color, className }: { color: string; className?: string }) {
  return <span aria-hidden className={cn('inline-block size-2.5 shrink-0 rounded-full', className)} style={{ background: color }} />;
}

/** Seconds since mount, ticking every quarter second. The first render is 0 on server and client. */
function useTicker(on: boolean) {
  const [sec, setSec] = useState(0);
  const t0 = useRef(0);
  useEffect(() => {
    if (!on) return;
    t0.current = performance.now() - sec * 1000;
    const id = window.setInterval(() => setSec(Math.floor((performance.now() - t0.current) / 1000)), 250);
    return () => window.clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [on]);
  return [sec, setSec] as const;
}

type Ctx = { t: Texts; locale: Locale; project: (id: string) => TimeProject | undefined; color: (id: string) => string; money: (n: number) => string };
type VP = Omit<TimeTrackerProps, 'variant' | 'sidebar' | 'labels' | 'className' | 'now'> & { ctx: Ctx; now: string; projects: TimeProject[]; entries: TimeEntry[] };

/* ------------------------------------------------------------------ */
/* Entry row                                                           */
/* ------------------------------------------------------------------ */

function EntryRow({ e, ctx, onResume, onRemove, onToggleBillable, onEdit, fresh }: { e: TimeEntry; ctx: Ctx; onResume?: () => void; onRemove: () => void; onToggleBillable?: () => void; onEdit?: () => void; fresh?: boolean }) {
  const { t, locale } = ctx;
  const p = ctx.project(e.project);
  const min = minutesOf(e);
  return (
    <li className={cn('group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-b border-border px-3 py-2.5 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto_auto_auto] sm:px-4', fresh && 'ttk-fly ttk-flash')}>
      <div className="min-w-0">
        <p className="truncate text-[14px] text-foreground">{e.note || e.task || p?.name}</p>
        <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] text-muted-foreground">
          <Dot color={ctx.color(e.project)} className="size-2" />
          <span className="truncate">
            {p?.name}
            {e.task ? ` · ${e.task}` : ''}
          </span>
        </p>
      </div>
      <span className="text-right text-[12.5px] tabular-nums text-muted-foreground max-sm:order-3 max-sm:col-span-1 max-sm:text-left">
        {formatTime(e.start, locale)}
        <span aria-hidden>–</span>
        <span className="sr-only"> {t.to} </span>
        {formatTime(e.end, locale)}
      </span>
      <span className="w-16 text-right text-[14px] font-semibold tabular-nums text-foreground max-sm:row-start-1 max-sm:col-start-2" aria-label={dur(min, t)}>
        {clock(min)}
      </span>
      <span className="flex items-center justify-end max-sm:order-4">
        {onToggleBillable && (
          <button type="button" aria-pressed={!!e.billable} aria-label={fill(t.toggleBillable, { p: p?.name ?? '' })} title={e.billable ? t.billable : t.notBillable} onClick={onToggleBillable} className={cn(iconBtn, e.billable ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-subtle-foreground')}>
            <Icon name={ctx.money(0).includes('€') ? 'euro' : 'dollar'} size={15} className={cn(!e.billable && 'opacity-50')} />
          </button>
        )}
        {onEdit && (
          <button type="button" aria-label={fill(t.edit, { p: e.note || p?.name || '' })} onClick={onEdit} className={iconBtn}>
            <Icon name="edit" size={15} />
          </button>
        )}
        {onResume && (
          <button type="button" aria-label={fill(t.resume, { p: e.note || p?.name || '' })} onClick={onResume} className={iconBtn}>
            <Icon name="play" size={15} />
          </button>
        )}
        <button type="button" aria-label={fill(t.remove, { p: e.note || p?.name || '' })} onClick={onRemove} className={cn(iconBtn, 'hover:text-destructive')}>
          <Icon name="trash" size={15} />
        </button>
      </span>
    </li>
  );
}

/* ------------------------------------------------------------------ */
/* timer                                                               */
/* ------------------------------------------------------------------ */

function Timer({ ctx, title, subtitle, projects, tasks = [], entries: initial, running, now, goal = 40, onAction }: VP) {
  const { t, locale } = ctx;
  const id = useId();
  const today = now.slice(0, 10);
  const nowMin = toMin(now.slice(11, 16));
  const [entries, setEntries] = useState(initial);
  const [note, setNote] = useState(running?.note ?? '');
  const [project, setProject] = useState(running?.project ?? projects[0]?.id ?? '');
  const [task, setTask] = useState(running?.task ?? tasks[0] ?? '');
  const [billable, setBillable] = useState(projects.find((p) => p.id === (running?.project ?? projects[0]?.id))?.billable ?? true);
  // Start of the running timer in seconds of the day, the clock is now plus the seconds since mount
  const [startSec, setStartSec] = useState<number | null>(running ? toMin(running.start) * 60 : null);
  const [since] = useTicker(true);
  const [say, setSay] = useState('');
  const [fresh, setFresh] = useState<string | null>(null);
  const [toast, show, hide] = useToast();
  const [scope, enter] = useEnter<HTMLDivElement>();
  const nowSec = nowMin * 60 + since;
  const elapsed = startSec == null ? 0 : Math.max(0, nowSec - startSec);
  const todays = entries.filter((e) => e.date === today);
  const todayMin = todays.reduce((a, e) => a + minutesOf(e), 0) + elapsed / 60;
  const mon = dayNum(mondayOf(today));
  const weekMin = entries.filter((e) => dayNum(e.date) >= mon && dayNum(e.date) < mon + 7).reduce((a, e) => a + minutesOf(e), 0) + elapsed / 60;
  const p = ctx.project(project);
  const start = () => {
    setStartSec(nowSec);
    setSay(fill(t.started, { p: p?.name ?? '' }));
    void onAction?.('start', { project, task, note, billable });
  };
  const stop = () => {
    if (startSec == null) return;
    const s = Math.floor(startSec / 60);
    const e = Math.max(s + 1, Math.floor(nowSec / 60));
    const entry: TimeEntry = { id: `e${Date.now()}`, project, task, note, date: today, start: hhmm(s), end: hhmm(e), billable };
    setEntries((x) => [entry, ...x]);
    setStartSec(null);
    setFresh(entry.id);
    setNote('');
    setSay(fill(t.stopped, { d: dur(e - s, t), p: p?.name ?? '' }));
    void onAction?.('stop', { entry });
  };
  const on = startSec != null;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
        <div className="grid min-w-0 gap-5">
          {/* The timer bar */}
          <section aria-label={t.start} className={cn('ttk-up relative overflow-hidden rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-500 sm:p-4', on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_9%,transparent),var(--shadow-md)]' : 'border-border')}>
            <div className="grid grid-cols-[minmax(0,1fr)] gap-2 md:grid-cols-[minmax(0,1fr)_auto] md:items-center lg:grid-cols-[minmax(0,1fr)] xl:grid-cols-[minmax(0,1fr)_auto]">
              <label className="block min-w-0">
                <span className="sr-only">{t.note}</span>
                <input value={note} onChange={(e) => setNote(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && (on ? stop() : start())} placeholder={t.note} className="h-12 w-full min-w-0 rounded-lg bg-transparent px-2 text-[16px] font-medium text-foreground outline-none placeholder:font-normal placeholder:text-subtle-foreground focus-visible:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)] focus-visible:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,var(--primary)_55%,transparent)]" />
              </label>
              <div className="flex items-center gap-3 max-md:hidden lg:justify-between xl:justify-start">
                <span className="relative inline-flex items-center gap-2">
                  {on && (
                    <span aria-hidden className="relative grid size-2.5 place-items-center">
                      <span className="ttk-pulse absolute size-2.5 rounded-full bg-primary" />
                      <span className="size-2.5 rounded-full bg-primary" />
                    </span>
                  )}
                  <span className={cn('min-w-[7.5ch] text-right text-[28px] font-semibold tabular-nums tracking-[-0.02em]', on ? 'text-foreground' : 'text-subtle-foreground')} aria-hidden>
                    {stopwatch(elapsed)}
                  </span>
                </span>
                <Button size="lg" magnetic variant={on ? 'destructive' : 'primary'} iconLeft={on ? 'stop' : 'play'} onClick={on ? stop : start}>
                  {on ? t.stop : t.start}
                </Button>
              </div>
            </div>
            <div className="mt-2 grid grid-cols-2 gap-2 border-t border-border pt-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] lg:grid-cols-2 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
              <Pick label={t.project} value={project} dot={ctx.color(project)} onChange={(v) => (setProject(v), setBillable(ctx.project(v)?.billable ?? true))}>
                {projects.map((x) => (
                  <option key={x.id} value={x.id}>
                    {x.client ? `${x.name} · ${x.client}` : x.name}
                  </option>
                ))}
              </Pick>
              <Pick label={t.task} value={task} onChange={setTask}>
                {tasks.map((x) => (
                  <option key={x} value={x}>
                    {x}
                  </option>
                ))}
              </Pick>
              <button type="button" aria-pressed={billable} onClick={() => setBillable(!billable)} className={cn('col-span-2 inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:col-span-1 sm:h-9 lg:col-span-2 xl:col-span-1', ring, billable ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                <Icon name={billable ? 'check-circle' : 'circle'} size={15} className={billable ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : ''} />
                {t.billable}
              </button>
            </div>
            {/* Phone: the time and the button get their own row */}
            <div className="mt-3 grid gap-3 border-t border-border pt-3 md:hidden">
              <p className="flex items-center justify-center gap-2.5">
                {on && (
                  <span aria-hidden className="relative grid size-2.5 place-items-center">
                    <span className="ttk-pulse absolute size-2.5 rounded-full bg-primary" />
                    <span className="size-2.5 rounded-full bg-primary" />
                  </span>
                )}
                <span aria-hidden className={cn('text-[40px] font-semibold tabular-nums tracking-[-0.03em]', on ? 'text-foreground' : 'text-subtle-foreground')}>
                  {stopwatch(elapsed)}
                </span>
              </p>
              <Button size="lg" variant={on ? 'destructive' : 'primary'} iconLeft={on ? 'stop' : 'play'} className="w-full" onClick={on ? stop : start}>
                {on ? t.stop : t.start}
              </Button>
            </div>
            <p className="mt-2 ps-2 text-[12.5px] text-muted-foreground">{on ? fill(t.running, { t: formatTime(hhmm(Math.floor((startSec ?? 0) / 60)), locale) }) : t.idle}</p>
          </section>
          <Card
            className="ttk-up [--ttk-d:80ms]"
            title={t.today}
            action={
              <span className="text-[13px] font-semibold tabular-nums text-foreground" aria-label={`${t.total} ${dur(Math.floor(todayMin), t)}`}>
                {clock(Math.floor(todayMin))}
              </span>
            }
            bodyClassName="p-0 sm:p-0"
          >
            {todays.length || on ? (
              <ul>
                {on && (
                  <li className="ttk-in grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 border-b border-border bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] px-3 py-2.5 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto_auto_auto] sm:px-4">
                    <div className="min-w-0">
                      <p className="truncate text-[14px] font-medium text-foreground">{note || p?.name}</p>
                      <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] text-muted-foreground">
                        <span aria-hidden className="relative grid size-2 place-items-center">
                          <span className="ttk-pulse absolute size-2 rounded-full bg-primary" />
                          <span className="size-2 rounded-full bg-primary" />
                        </span>
                        <span className="truncate">
                          {p?.name}
                          {task ? ` · ${task}` : ''}
                        </span>
                      </p>
                    </div>
                    <span className="text-right text-[12.5px] tabular-nums text-muted-foreground max-sm:hidden">{formatTime(hhmm(Math.floor((startSec ?? 0) / 60)), locale)}–</span>
                    <span className="w-16 text-right text-[14px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" aria-hidden>
                      {clock(Math.floor(elapsed / 60))}
                    </span>
                    <span className="flex justify-end max-sm:hidden">
                      <button type="button" aria-label={t.stop} onClick={stop} className={cn(iconBtn, 'text-destructive')}>
                        <Icon name="stop" size={15} />
                      </button>
                    </span>
                  </li>
                )}
                {todays.map((e) => (
                  <EntryRow
                    key={e.id}
                    e={e}
                    ctx={ctx}
                    fresh={fresh === e.id}
                    onResume={() => {
                      setProject(e.project);
                      setTask(e.task ?? task);
                      setNote(e.note ?? '');
                      setBillable(!!e.billable);
                      if (on) stop();
                      setStartSec(nowSec);
                      setSay(fill(t.started, { p: ctx.project(e.project)?.name ?? '' }));
                      document.getElementById(`${id}-top`)?.scrollIntoView({ block: 'nearest' });
                    }}
                    onRemove={() => {
                      const before = entries;
                      setEntries((x) => x.filter((y) => y.id !== e.id));
                      setSay(t.removed);
                      show(t.removed, () => (setEntries(before), hide()));
                      void onAction?.('delete', { id: e.id });
                    }}
                  />
                ))}
              </ul>
            ) : (
              <p className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.noEntries}</p>
            )}
          </Card>
        </div>
        <aside id={`${id}-top`} className="ttk-up grid gap-5 [--ttk-d:140ms] lg:sticky lg:top-6">
          <Card title={t.thisWeek}>
            <p className="text-[28px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{clock(Math.floor(weekMin))}</p>
            <p className="text-[13px] text-muted-foreground">{fill(t.weekOf, { h: new Intl.NumberFormat(tagOf(locale), { maximumFractionDigits: 1 }).format(round(weekMin / 60, 1)), g: goal })}</p>
            <Bar value={(weekMin / 60 / goal) * 100} className="mt-3" label={t.goal} />
            <ul className="mt-5 grid gap-2.5">
              {projects.map((x) => {
                const m = entries.filter((e) => e.project === x.id && dayNum(e.date) >= mon && dayNum(e.date) < mon + 7).reduce((a, e) => a + minutesOf(e), 0) + (on && x.id === project ? elapsed / 60 : 0);
                if (!m) return null;
                return (
                  <li key={x.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 text-[13px]">
                    <Dot color={ctx.color(x.id)} />
                    <span className="truncate text-foreground">{x.name}</span>
                    <span className="tabular-nums text-muted-foreground">{clock(Math.floor(m))}</span>
                  </li>
                );
              })}
            </ul>
          </Card>
        </aside>
      </div>
      <Live text={say} />
      <Toast t={t} text={toast?.text ?? ''} onUndo={toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* timesheet                                                           */
/* ------------------------------------------------------------------ */

const parseHours = (s: string) => {
  const v = s.trim().replace(',', '.');
  if (!v) return 0;
  const m = /^(\d{1,2}):(\d{1,2})$/.exec(v);
  if (m) return Number(m[1]) + Number(m[2]) / 60;
  const n = Number(v);
  return Number.isFinite(n) ? Math.max(0, Math.min(24, n)) : null;
};
const showHours = (h: number) => (h ? clock(Math.round(h * 60)) : '');

function Timesheet({ ctx, title, subtitle, projects, tasks = [], rows: initial = [], now, dailyGoal = 8, goal = 40, onAction }: VP) {
  const { t, locale } = ctx;
  const id = useId();
  const week0 = mondayOf(now.slice(0, 10));
  const today = now.slice(0, 10);
  const [week, setWeek] = useState(week0);
  const [sheets, setSheets] = useState<Record<string, TimesheetRow[]>>({ [week0]: initial });
  const [drafts, setDrafts] = useState<Record<string, string>>({});
  const [state, setState] = useState<Record<string, 'busy' | 'done'>>({});
  const [newProject, setNewProject] = useState(projects[0]?.id ?? '');
  const [mday, setMday] = useState(Math.max(0, Math.min(6, dayNum(today) - dayNum(week0))));
  const [say, setSay] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const rows = sheets[week] ?? [];
  const locked = state[week] === 'done';
  const days = Array.from({ length: 7 }, (_, i) => isoOf(dayNum(week) + i));
  const colTotal = (i: number) => rows.reduce((a, r) => a + (r.hours[i] ?? 0), 0);
  const total = rows.reduce((a, r) => a + r.hours.reduce((x, y) => x + y, 0), 0);
  const setRows = (fn: (r: TimesheetRow[]) => TimesheetRow[]) => setSheets((s) => ({ ...s, [week]: fn(s[week] ?? []) }));
  const setCell = (rid: string, i: number, h: number) => setRows((rs) => rs.map((r) => (r.id === rid ? { ...r, hours: r.hours.map((x, k) => (k === i ? round(h, 2) : x)) } : r)));
  const name = (r: TimesheetRow) => ctx.project(r.project)?.name ?? r.project;
  const prevWeek = isoOf(dayNum(week) - 7);
  const go = (d: number) => {
    setWeek((w) => isoOf(dayNum(w) + d * 7));
    setDrafts({});
  };
  const weekLabel = `${fmtDay(days[0], locale, { day: 'numeric', month: 'short' })}–${fmtDay(days[6], locale, { day: 'numeric', month: 'short' })}`;
  const focusCell = (r: number, c: number) => document.getElementById(`${id}-${r}-${c}`)?.focus();
  const onKey = (e: React.KeyboardEvent<HTMLInputElement>, r: number, c: number) => {
    const el = e.currentTarget;
    const atStart = el.selectionStart === 0 && el.selectionEnd === 0;
    const atEnd = el.selectionStart === el.value.length;
    if (e.key === 'ArrowUp' || (e.key === 'Enter' && e.shiftKey)) (e.preventDefault(), focusCell(r - 1, c));
    else if (e.key === 'ArrowDown' || e.key === 'Enter') (e.preventDefault(), focusCell(r + 1, c));
    else if (e.key === 'ArrowLeft' && atStart) (e.preventDefault(), focusCell(r, c - 1));
    else if (e.key === 'ArrowRight' && atEnd) (e.preventDefault(), focusCell(r, c + 1));
  };
  const dayHead = (d: string, i: number, short = false) => (
    <span className="flex flex-col items-center leading-tight">
      <span className={cn('text-[12px] font-medium', d === today ? 'text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}</span>
      {!short && <span className={cn('text-[11.5px] tabular-nums', d === today ? 'font-semibold text-foreground' : 'text-subtle-foreground')}>{fmtDay(d, locale, { day: 'numeric' })}</span>}
      {i < 0 && null}
    </span>
  );
  const status = (
    <div className="flex flex-wrap items-center gap-2">
      <div className="flex items-center rounded-lg border border-border bg-card">
        <button type="button" aria-label={t.prevWeek} onClick={() => go(-1)} className={iconBtn}>
          <Icon name="chevron-left" size={16} />
        </button>
        <span className="min-w-32 px-1 text-center text-[13px] font-medium tabular-nums text-foreground" aria-live="polite">
          {weekLabel}
        </span>
        <button type="button" aria-label={t.nextWeek} onClick={() => go(1)} className={iconBtn}>
          <Icon name="chevron-right" size={16} />
        </button>
      </div>
      {locked ? (
        <span className="ttk-in inline-flex h-9 items-center gap-1.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_13%,var(--card))] px-3 text-[13px] font-medium text-success-foreground">
          <Icon name="check-circle" size={15} />
          {t.submitted}
        </span>
      ) : (
        <Button
          size="sm"
          iconLeft={state[week] === 'busy' ? 'loader' : 'send'}
          disabled={!total || state[week] === 'busy'}
          onClick={async () => {
            setState((s) => ({ ...s, [week]: 'busy' }));
            if (onAction) await onAction('submit', { week, rows });
            else await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 800));
            setState((s) => ({ ...s, [week]: 'done' }));
            setSay(t.submittedSay);
          }}
        >
          {state[week] === 'busy' ? t.submitting : t.submit}
        </Button>
      )}
    </div>
  );
  const addRow = (
    <form
      className="flex gap-2"
      onSubmit={(e) => {
        e.preventDefault();
        setRows((rs) => [...rs, { id: `r${Date.now()}`, project: newProject, task: tasks[0], hours: [0, 0, 0, 0, 0, 0, 0] }]);
        requestAnimationFrame(() => focusCell(rows.length, Math.min(mday, 6)));
      }}
    >
      <Pick label={t.project} value={newProject} dot={ctx.color(newProject)} onChange={setNewProject} className="w-0 flex-1 sm:w-56 sm:flex-none">
        {projects.map((x) => (
          <option key={x.id} value={x.id}>
            {x.name}
          </option>
        ))}
      </Pick>
      <Button type="submit" size="sm" variant="outline" iconLeft="plus" disabled={locked} className="max-sm:h-11">
        {t.addRow}
      </Button>
    </form>
  );
  const cellInput = (r: TimesheetRow, ri: number, i: number) => {
    const k = `${r.id}-${i}`;
    const v = drafts[k] ?? showHours(r.hours[i] ?? 0);
    const bad = drafts[k] != null && parseHours(drafts[k]) == null;
    return (
      <input
        id={`${id}-${ri}-${i}`}
        inputMode="decimal"
        aria-label={fill(t.cell, { p: name(r), d: fmtDay(days[i], locale, { weekday: 'long', day: 'numeric', month: 'long' }) })}
        aria-invalid={bad || undefined}
        readOnly={locked}
        value={v}
        onFocus={(e) => e.currentTarget.select()}
        onChange={(e) => setDrafts((d) => ({ ...d, [k]: e.target.value }))}
        onBlur={() => {
          const h = drafts[k] != null ? parseHours(drafts[k]) : null;
          if (h != null) {
            setCell(r.id, i, h);
            void onAction?.('update', { row: r.id, day: days[i], hours: h });
          }
          setDrafts((d) => {
            const n = { ...d };
            delete n[k];
            return n;
          });
        }}
        onKeyDown={(e) => onKey(e, ri, i)}
        className={cn(
          'h-10 w-full min-w-0 rounded-md border bg-transparent px-1 text-center text-[13.5px] tabular-nums text-foreground outline-none transition-[border-color,background-color,box-shadow] placeholder:text-[color-mix(in_oklab,var(--foreground)_22%,transparent)] hover:border-border focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:bg-background focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_14%,transparent)] read-only:hover:border-transparent',
          bad ? 'border-destructive' : 'border-transparent',
          (r.hours[i] ?? 0) > 0 && 'font-medium',
        )}
      />
    );
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        {status}
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
        {/* Summary strip */}
        <div className="ttk-up grid grid-cols-[minmax(0,1fr)] gap-3 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:gap-6 sm:p-5">
          <div>
            <p className="text-[12.5px] text-muted-foreground">{t.week}</p>
            <p className="text-[28px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
              {clock(Math.round(total * 60))}
              <span className="ms-1.5 text-[14px] font-medium text-subtle-foreground">/ {goal}:00</span>
            </p>
          </div>
          <div>
            <Bar value={(total / goal) * 100} label={t.goal} />
            <p className="mt-2 text-[12.5px] text-muted-foreground">{total >= goal ? t.goalMet : fill(t.goalLeft, { h: new Intl.NumberFormat(tagOf(locale), { maximumFractionDigits: 1 }).format(round(goal - total, 1)) })}</p>
          </div>
        </div>
        {!rows.length && (
          <div className="ttk-in grid place-items-center gap-3 rounded-xl border border-dashed border-border px-4 py-10 text-center">
            <p className="text-[13.5px] text-muted-foreground">{t.emptyWeek}</p>
            {(sheets[prevWeek]?.length ?? 0) > 0 && (
              <Button size="sm" variant="outline" iconLeft="copy" onClick={() => setRows(() => (sheets[prevWeek] ?? []).map((r) => ({ ...r, id: `${r.id}-${week}`, hours: [0, 0, 0, 0, 0, 0, 0] })))}>
                {t.copyLast}
              </Button>
            )}
          </div>
        )}
        {rows.length > 0 && (
          <>
            {/* Tablet and desktop: projects by days */}
            <div className="ttk-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--ttk-d:80ms] max-md:hidden">
              <table className="w-full table-fixed border-collapse">
                <caption className="sr-only">
                  {title} {weekLabel}
                </caption>
                <thead>
                  <tr className="border-b border-border bg-surface">
                    <th scope="col" className="w-[30%] px-4 py-2.5 text-left text-[12px] font-medium text-muted-foreground lg:w-[26%]">
                      {t.project}
                    </th>
                    {days.map((d, i) => (
                      <th key={d} scope="col" className={cn('px-1 py-2', i >= 5 && 'bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]', d === today && 'shadow-[inset_0_-2px_0_var(--primary)]')}>
                        {dayHead(d, i)}
                      </th>
                    ))}
                    <th scope="col" className="w-20 px-3 py-2.5 text-right text-[12px] font-medium text-muted-foreground">
                      {t.total}
                    </th>
                    <th scope="col" className="w-12">
                      <span className="sr-only">{t.removeRow}</span>
                    </th>
                  </tr>
                </thead>
                <tbody>
                  {rows.map((r, ri) => {
                    const sum = r.hours.reduce((a, b) => a + b, 0);
                    return (
                      <tr key={r.id} className="ttk-in border-b border-border transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_1.5%,transparent)]">
                        <th scope="row" className="px-4 py-1.5 text-left font-normal">
                          <span className="flex min-w-0 items-center gap-2">
                            <Dot color={ctx.color(r.project)} />
                            <span className="min-w-0">
                              <span className="block truncate text-[13.5px] font-medium text-foreground">{name(r)}</span>
                              {r.task && <span className="block truncate text-[12px] text-muted-foreground">{r.task}</span>}
                            </span>
                          </span>
                        </th>
                        {days.map((d, i) => (
                          <td key={d} className={cn('px-1 py-1', i >= 5 && 'bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
                            {cellInput(r, ri, i)}
                          </td>
                        ))}
                        <td className="px-3 py-1 text-right text-[13.5px] font-semibold tabular-nums text-foreground" aria-label={fill(t.rowTotal, { p: name(r) })}>
                          {clock(Math.round(sum * 60))}
                        </td>
                        <td className="pe-2">
                          <button type="button" aria-label={fill(t.removeRow, { p: name(r) })} disabled={locked} onClick={() => setRows((rs) => rs.filter((x) => x.id !== r.id))} className={cn(iconBtn, 'hover:text-destructive')}>
                            <Icon name="x" size={15} />
                          </button>
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
                <tfoot>
                  <tr className="bg-surface">
                    <th scope="row" className="px-4 py-3 text-left text-[12.5px] font-semibold text-foreground">
                      {t.total}
                    </th>
                    {days.map((d, i) => {
                      const c = colTotal(i);
                      return (
                        <td key={d} className={cn('px-1 py-2 text-center', i >= 5 && 'bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
                          <span className={cn('block text-[13px] font-semibold tabular-nums', c >= dailyGoal ? 'text-success-foreground' : c ? 'text-foreground' : 'text-subtle-foreground')}>{c ? clock(Math.round(c * 60)) : '0:00'}</span>
                          {i < 5 && (
                            <span aria-hidden className="mx-auto mt-1 block h-1 w-8 overflow-hidden rounded-full bg-track">
                              <span className={cn('block h-full rounded-full transition-[width] duration-500', c >= dailyGoal ? 'bg-success' : 'bg-primary')} style={{ width: `${round(Math.min(100, (c / dailyGoal) * 100), 1)}%` }} />
                            </span>
                          )}
                        </td>
                      );
                    })}
                    <td className="px-3 py-2 text-right text-[14px] font-semibold tabular-nums text-foreground">{clock(Math.round(total * 60))}</td>
                    <td />
                  </tr>
                </tfoot>
              </table>
            </div>
            {/* Phone: a day at a time, steppers instead of a grid */}
            <div className="grid gap-3 md:hidden">
              <div role="radiogroup" aria-label={t.day} className="grid grid-cols-7 gap-1">
                {days.map((d, i) => {
                  const c = colTotal(i);
                  return (
                    <button
                      key={d}
                      type="button"
                      role="radio"
                      aria-checked={mday === i}
                      tabIndex={mday === i ? 0 : -1}
                      aria-label={`${fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'long' })}, ${clock(Math.round(c * 60))}`}
                      onClick={() => setMday(i)}
                      onKeyDown={(e) => {
                        const k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
                        if (!k) return;
                        e.preventDefault();
                        const n = (i + k + 7) % 7;
                        setMday(n);
                        (e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
                      }}
                      className={cn('flex h-16 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-lg border transition-colors', ring, mday === i ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-foreground')}
                    >
                      <span className={cn('text-[11.5px] font-medium', mday === i ? 'opacity-80' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'narrow' })}</span>
                      <span className="text-[12.5px] font-semibold tabular-nums">{c ? clock(Math.round(c * 60)) : '0'}</span>
                    </button>
                  );
                })}
              </div>
              <ul key={mday} className="ttk-in overflow-hidden rounded-xl border border-border bg-card">
                {rows.map((r) => {
                  const h = r.hours[mday] ?? 0;
                  return (
                    <li key={r.id} className="flex items-center gap-2 border-b border-border px-3 py-2 last:border-b-0">
                      <Dot color={ctx.color(r.project)} />
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-[14px] font-medium text-foreground">{name(r)}</span>
                        {r.task && <span className="block truncate text-[12px] text-muted-foreground">{r.task}</span>}
                      </span>
                      <button type="button" aria-label={fill(t.less, { p: name(r) })} disabled={locked || h <= 0} onClick={() => setCell(r.id, mday, Math.max(0, h - 0.25))} className={cn(iconBtn, 'border border-border')}>
                        <Icon name="minus" size={15} />
                      </button>
                      <span className="w-12 text-center text-[15px] font-semibold tabular-nums text-foreground" aria-live="polite">
                        {clock(Math.round(h * 60))}
                      </span>
                      <button type="button" aria-label={fill(t.more, { p: name(r) })} disabled={locked || h >= 24} onClick={() => setCell(r.id, mday, h + 0.25)} className={cn(iconBtn, 'border border-border')}>
                        <Icon name="plus" size={15} />
                      </button>
                    </li>
                  );
                })}
              </ul>
              <p className="text-center text-[12.5px] text-muted-foreground">{fill(t.goalDay, { h: clock(Math.round(colTotal(mday) * 60)), g: dailyGoal })}</p>
            </div>
          </>
        )}
        {!locked && <div className="ttk-up [--ttk-d:120ms]">{addRow}</div>}
      </div>
      <Live text={say} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* entries                                                             */
/* ------------------------------------------------------------------ */

function Entries({ ctx, title, subtitle, projects, tasks = [], entries: initial, onAction }: VP) {
  const { t, locale } = ctx;
  const id = useId();
  const [entries, setEntries] = useState(initial);
  const [proj, setProj] = useState('all');
  const [onlyBill, setOnlyBill] = useState(false);
  const [editing, setEditing] = useState<TimeEntry | null>(null);
  const [say, setSay] = useState('');
  const [toast, show, hide] = useToast();
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = entries.filter((e) => (proj === 'all' || e.project === proj) && (!onlyBill || e.billable));
  const dates = [...new Set(shown.map((e) => e.date))].sort().reverse();
  const totalMin = shown.reduce((a, e) => a + minutesOf(e), 0);
  const billMin = shown.filter((e) => e.billable).reduce((a, e) => a + minutesOf(e), 0);
  const amount = shown.filter((e) => e.billable).reduce((a, e) => a + (minutesOf(e) / 60) * (ctx.project(e.project)?.rate ?? 0), 0);
  const kpi = 'text-[12.5px] text-muted-foreground';
  const big = 'mt-1 text-[24px] font-semibold tabular-nums tracking-[-0.02em] text-foreground';
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
        <dl className="ttk-up grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)] md:grid-cols-4">
          {[
            { k: t.totalHours, v: clock(totalMin) },
            { k: t.billable, v: clock(billMin) },
            { k: t.billableShare, v: formatPercent(totalMin ? Math.round((billMin / totalMin) * 100) : 0, locale) },
            { k: t.amount, v: ctx.money(amount) },
          ].map((x) => (
            <div key={x.k} className="bg-card px-4 py-3.5 sm:px-5">
              <dt className={kpi}>{x.k}</dt>
              <dd key={x.v} className={cn(big, 'ttk-in')}>
                {x.v}
              </dd>
            </div>
          ))}
        </dl>
        <div className="ttk-up flex flex-col gap-2 [--ttk-d:60ms] sm:flex-row sm:items-center">
          <Pick label={t.project} value={proj} onChange={setProj} dot={proj === 'all' ? undefined : ctx.color(proj)} className="sm:w-60">
            <option value="all">{t.allProjects}</option>
            {projects.map((x) => (
              <option key={x.id} value={x.id}>
                {x.name}
              </option>
            ))}
          </Pick>
          <button type="button" aria-pressed={onlyBill} onClick={() => setOnlyBill(!onlyBill)} className={cn('inline-flex h-11 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-9', ring, onlyBill ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
            <Icon name={onlyBill ? 'check-circle' : 'circle'} size={15} />
            {t.onlyBillable}
          </button>
        </div>
        {dates.map((d, di) => {
          const list = shown.filter((e) => e.date === d).sort((a, b) => toMin(b.start) - toMin(a.start));
          const sum = list.reduce((a, e) => a + minutesOf(e), 0);
          return (
            <section key={d} aria-labelledby={`${id}-${d}`} className="ttk-up" style={{ '--ttk-d': `${100 + di * 60}ms` } as React.CSSProperties}>
              <div className="mb-2 flex items-baseline justify-between gap-3 px-1">
                <h2 id={`${id}-${d}`} className="text-[13.5px] font-semibold text-foreground">
                  {fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'long' })}
                </h2>
                <span className="text-[13px] font-semibold tabular-nums text-muted-foreground">{clock(sum)}</span>
              </div>
              <ul className="overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
                {list.map((e) =>
                  editing?.id === e.id ? (
                    <li key={e.id} className="ttk-in border-b border-border bg-surface p-3 last:border-b-0 sm:p-4">
                      <form
                        className="grid grid-cols-2 gap-2 sm:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_7rem_7rem]"
                        onSubmit={(ev) => {
                          ev.preventDefault();
                          if (toMin(editing.end) <= toMin(editing.start)) return;
                          setEntries((x) => x.map((y) => (y.id === e.id ? editing : y)));
                          setEditing(null);
                          setSay(t.saved);
                          void onAction?.('update', { entry: editing });
                          requestAnimationFrame(() => document.getElementById(`${id}-e-${e.id}`)?.focus());
                        }}
                      >
                        <label className="col-span-2 sm:col-span-1">
                          <span className="sr-only">{t.note}</span>
                          <input autoFocus value={editing.note ?? ''} placeholder={t.note} onChange={(ev) => setEditing({ ...editing, note: ev.target.value })} className={field} />
                        </label>
                        <Pick label={t.project} value={editing.project} dot={ctx.color(editing.project)} onChange={(v) => setEditing({ ...editing, project: v })}>
                          {projects.map((x) => (
                            <option key={x.id} value={x.id}>
                              {x.name}
                            </option>
                          ))}
                        </Pick>
                        <Pick label={t.task} value={editing.task ?? ''} onChange={(v) => setEditing({ ...editing, task: v })}>
                          {tasks.map((x) => (
                            <option key={x} value={x}>
                              {x}
                            </option>
                          ))}
                        </Pick>
                        <label className="relative">
                          <span className="sr-only">{t.from}</span>
                          <input type="time" value={editing.start} onChange={(ev) => setEditing({ ...editing, start: ev.target.value })} className={cn(field, 'tabular-nums')} />
                        </label>
                        <label className="relative">
                          <span className="sr-only">{t.to}</span>
                          <input type="time" value={editing.end} aria-invalid={toMin(editing.end) <= toMin(editing.start) || undefined} onChange={(ev) => setEditing({ ...editing, end: ev.target.value })} className={cn(field, 'tabular-nums aria-[invalid]:border-destructive')} />
                        </label>
                        <div className="col-span-2 flex justify-end gap-2 sm:col-span-5">
                          <Button type="button" size="sm" variant="ghost" onClick={() => setEditing(null)}>
                            {t.cancel}
                          </Button>
                          <Button type="submit" size="sm" iconLeft="check">
                            {t.save}
                          </Button>
                        </div>
                      </form>
                    </li>
                  ) : (
                    <EntryRow
                      key={e.id}
                      e={e}
                      ctx={ctx}
                      onEdit={() => setEditing(e)}
                      onToggleBillable={() => {
                        setEntries((x) => x.map((y) => (y.id === e.id ? { ...y, billable: !y.billable } : y)));
                        setSay(`${e.billable ? t.notBillable : t.billable}: ${e.note || ctx.project(e.project)?.name}`);
                        void onAction?.('update', { id: e.id, billable: !e.billable });
                      }}
                      onRemove={() => {
                        const before = entries;
                        setEntries((x) => x.filter((y) => y.id !== e.id));
                        setSay(t.removed);
                        show(t.removed, () => (setEntries(before), hide()));
                        void onAction?.('delete', { id: e.id });
                      }}
                    />
                  ),
                )}
              </ul>
            </section>
          );
        })}
        {!dates.length && <p className="rounded-xl border border-dashed border-border px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.noEntries}</p>}
      </div>
      <Live text={say} />
      <Toast t={t} text={toast?.text ?? ''} onUndo={toast?.undo} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* week-summary                                                        */
/* ------------------------------------------------------------------ */

function WeekSummary({ ctx, title, subtitle, projects, entries, now, goal = 40, dailyGoal = 8 }: VP) {
  const { t, locale } = ctx;
  const [which, setWhich] = useState<'this' | 'last'>('this');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const mon = dayNum(mondayOf(now.slice(0, 10))) - (which === 'last' ? 7 : 0);
  const days = Array.from({ length: 7 }, (_, i) => isoOf(mon + i));
  const list = entries.filter((e) => dayNum(e.date) >= mon && dayNum(e.date) < mon + 7);
  const totalMin = list.reduce((a, e) => a + minutesOf(e), 0);
  const billMin = list.filter((e) => e.billable).reduce((a, e) => a + minutesOf(e), 0);
  const amount = list.filter((e) => e.billable).reduce((a, e) => a + (minutesOf(e) / 60) * (ctx.project(e.project)?.rate ?? 0), 0);
  const used = projects.filter((p) => list.some((e) => e.project === p.id));
  const worked = days.filter((d) => list.some((e) => e.date === d)).length || 1;
  const data = days.map((d) => {
    const row: Record<string, string | number> = { day: d };
    used.forEach((p) => (row[p.id] = round(list.filter((e) => e.date === d && e.project === p.id).reduce((a, e) => a + minutesOf(e), 0) / 60, 2)));
    return row;
  });
  const hours = new Intl.NumberFormat(tagOf(locale), { maximumFractionDigits: 1 });
  const share = billMin && totalMin ? Math.round((billMin / totalMin) * 100) : 0;
  const kpis = [
    { k: t.totalHours, v: clock(totalMin), sub: fill(t.goalDay, { h: hours.format(round(totalMin / 60, 1)), g: goal }), bar: (totalMin / 60 / goal) * 100 },
    { k: t.billableShare, v: formatPercent(share, locale), sub: `${clock(billMin)} ${t.billable.toLowerCase()}`, bar: share },
    { k: t.amount, v: ctx.money(amount), sub: fill(t.fromProjects, { n: used.filter((p) => list.some((e) => e.project === p.id && e.billable)).length }) },
    { k: t.avgDay, v: clock(Math.round(totalMin / worked)), sub: fill(t.goalDay, { h: hours.format(round(totalMin / worked / 60, 1)), g: dailyGoal }), bar: (totalMin / worked / 60 / dailyGoal) * 100 },
  ];
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.week}
          value={which}
          onChange={setWhich}
          options={[
            { value: 'this', label: t.thisWeek },
            { value: 'last', label: t.lastWeek },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} key={which} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
        <dl className="grid grid-cols-2 gap-3 xl:grid-cols-4">
          {kpis.map((x, i) => (
            <div key={x.k} className="ttk-up min-w-0 rounded-xl border border-border bg-card p-3.5 shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 hover:-translate-y-0.5 sm:p-5" style={{ '--ttk-d': `${i * 60}ms` } as React.CSSProperties}>
              <dt className="text-[12.5px] text-muted-foreground">{x.k}</dt>
              <dd className="mt-1 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground sm:text-[26px]">{x.v}</dd>
              <dd className="mt-0.5 truncate text-[12.5px] text-muted-foreground">{x.sub}</dd>
              {x.bar != null && <Bar value={x.bar} className="mt-3 h-1.5" />}
            </div>
          ))}
        </dl>
        <div className="grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
          <Card title={t.byDay} className="ttk-up [--ttk-d:120ms]">
            <BarChart label={t.byDay} data={data} xKey="day" xFormat={{ weekday: 'short' }} tooltipXFormat={{ weekday: 'long', day: 'numeric', month: 'long' }} stacked series={used.map((p) => ({ key: p.id, label: p.name, color: ctx.color(p.id) }))} format={{ style: 'unit', unit: 'hour', unitDisplay: 'short', maximumFractionDigits: 1 }} height={250} />
          </Card>
          <Card title={t.share} className="ttk-up [--ttk-d:180ms]">
            <DonutChart label={t.byProject} size={168} legend="bottom" centerValue={clock(totalMin)} centerLabel={t.total} data={used.map((p) => ({ label: p.name, value: round(list.filter((e) => e.project === p.id).reduce((a, e) => a + minutesOf(e), 0) / 60, 2), color: ctx.color(p.id) }))} format={{ style: 'unit', unit: 'hour', unitDisplay: 'short', maximumFractionDigits: 1 }} />
          </Card>
        </div>
        <Card title={t.byProject} className="ttk-up [--ttk-d:220ms]" bodyClassName="p-0 sm:p-0">
          <ul>
            {used
              .map((p) => ({ p, m: list.filter((e) => e.project === p.id).reduce((a, e) => a + minutesOf(e), 0), b: list.filter((e) => e.project === p.id && e.billable).reduce((a, e) => a + minutesOf(e), 0) }))
              .sort((a, b) => b.m - a.m)
              .map(({ p, m, b }) => (
                <li key={p.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 border-b border-border px-4 py-3 last:border-b-0 sm:grid-cols-[auto_minmax(0,12rem)_minmax(0,1fr)_5rem_7rem] sm:px-5">
                  <Dot color={ctx.color(p.id)} />
                  <span className="min-w-0">
                    <span className="block truncate text-[13.5px] font-medium text-foreground">{p.name}</span>
                    {p.client && <span className="block truncate text-[12px] text-muted-foreground">{p.client}</span>}
                  </span>
                  <span className="col-span-3 col-start-1 flex items-center gap-2 sm:col-span-1 sm:col-start-auto">
                    <span aria-hidden className="relative h-2 flex-1 overflow-hidden rounded-full bg-track">
                      <span className="ttk-grow absolute inset-y-0 start-0 rounded-full" style={{ width: `${round((m / Math.max(1, totalMin)) * 100, 1)}%`, background: ctx.color(p.id) }} />
                    </span>
                    <span className="w-10 text-right text-[12px] tabular-nums text-muted-foreground">{formatPercent(Math.round((m / Math.max(1, totalMin)) * 100), locale)}</span>
                  </span>
                  <span className="row-start-1 text-right text-[13.5px] font-semibold tabular-nums text-foreground max-sm:col-start-3 sm:row-start-auto">{clock(m)}</span>
                  <span className="text-right text-[12.5px] tabular-nums text-muted-foreground max-sm:hidden">{b ? ctx.money((b / 60) * (p.rate ?? 0)) : t.notBillable}</span>
                </li>
              ))}
          </ul>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* focus                                                               */
/* ------------------------------------------------------------------ */

type Mode = 'work' | 'short' | 'long';

function Focus({ ctx, title, subtitle, projects, entries: initial, now, focus = { work: 25, short: 5, long: 15, rounds: 4 }, onAction }: VP) {
  const { t, locale } = ctx;
  const id = useId();
  const today = now.slice(0, 10);
  const startMin = toMin(now.slice(11, 16));
  const [mode, setMode] = useState<Mode>('work');
  const [left, setLeft] = useState(focus.work * 60);
  const [on, setOn] = useState(false);
  const [round_, setRound] = useState(1);
  const [project, setProject] = useState(projects[0]?.id ?? '');
  const [note, setNote] = useState('');
  const [log, setLog] = useState(initial.filter((e) => e.date === today));
  const [say, setSay] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const clockRef = useRef(startMin * 60);
  const total = (m: Mode) => (m === 'work' ? focus.work : m === 'short' ? focus.short : focus.long) * 60;
  const setM = (m: Mode) => {
    setMode(m);
    setLeft(total(m));
    setOn(false);
  };
  const finish = () => {
    setOn(false);
    if (mode === 'work') {
      const end = Math.floor(clockRef.current / 60);
      const entry: TimeEntry = { id: `f${Date.now()}`, project, note: note || t.focus, task: t.focus, date: today, start: hhmm(end - focus.work), end: hhmm(end), billable: ctx.project(project)?.billable };
      setLog((l) => [entry, ...l]);
      void onAction?.('focus-done', { entry });
      setSay(t.focusDone);
      const next: Mode = round_ % focus.rounds === 0 ? 'long' : 'short';
      setMode(next);
      setLeft(total(next));
    } else {
      setSay(t.breakDone);
      setRound((r) => (mode === 'long' ? 1 : r + 1));
      setMode('work');
      setLeft(total('work'));
    }
  };
  useEffect(() => {
    if (!on) return;
    let last = performance.now();
    const iv = window.setInterval(() => {
      const n = performance.now();
      const d = Math.floor((n - last) / 1000);
      if (!d) return;
      last += d * 1000;
      clockRef.current += d;
      setLeft((x) => Math.max(0, x - d));
    }, 200);
    return () => window.clearInterval(iv);
  }, [on]);
  useEffect(() => {
    if (on && left === 0) finish();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [left, on]);
  const full = total(mode);
  const pct = 1 - left / full;
  const R = 112;
  const C = 2 * Math.PI * R;
  const mm = `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`;
  const focusMin = log.filter((e) => e.task === t.focus).reduce((a, e) => a + minutesOf(e), 0);
  const tone = mode === 'work' ? 'var(--primary)' : 'var(--success)';
  return (
    <Main className="max-w-[68rem]">
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] lg:items-start">
        <section aria-labelledby={`${id}-t`} className="ttk-up kit-card relative flex flex-col items-center overflow-hidden rounded-2xl border border-border bg-card px-4 pb-6 pt-5 shadow-[var(--highlight),var(--shadow-sm)] sm:px-8 sm:pb-8">
          <h2 id={`${id}-t`} className="sr-only">
            {t.focus}
          </h2>
          <Segmented
            label={t.mode}
            value={mode}
            onChange={setM}
            className="w-full sm:w-auto"
            options={[
              { value: 'work', label: t.focus },
              { value: 'short', label: t.shortBreak },
              { value: 'long', label: t.longBreak },
            ]}
          />
          <div className="relative mt-6 aspect-square w-full max-w-[17rem] sm:max-w-[18.5rem]">
            <svg viewBox="0 0 260 260" className="size-full -rotate-90" aria-hidden>
              {Array.from({ length: 60 }, (_, i) => {
                const a = (i / 60) * Math.PI * 2;
                const r1 = 126;
                const r2 = i % 5 === 0 ? 120 : 123;
                return <line key={i} x1={round(130 + Math.cos(a) * r1)} y1={round(130 + Math.sin(a) * r1)} x2={round(130 + Math.cos(a) * r2)} y2={round(130 + Math.sin(a) * r2)} stroke="var(--border-strong)" strokeWidth={i % 5 === 0 ? 1.5 : 1} />;
              })}
              <circle cx="130" cy="130" r={R} fill="none" stroke="color-mix(in oklab, var(--foreground) 9%, transparent)" strokeWidth="10" />
              <circle cx="130" cy="130" r={R} fill="none" stroke={tone} strokeWidth="10" strokeLinecap="round" strokeDasharray={round(C, 2)} strokeDashoffset={round(C * (1 - pct), 2)} className="transition-[stroke-dashoffset,stroke] duration-700 ease-linear motion-reduce:transition-none" />
            </svg>
            <div className="absolute inset-0 grid place-items-center">
              <div className="relative text-center">
                <p className="text-[13px] font-medium text-muted-foreground">{mode === 'work' ? fill(t.round, { n: ((round_ - 1) % focus.rounds) + 1, m: focus.rounds }) : mode === 'short' ? t.shortBreak : t.longBreak}</p>
                <p className="text-[clamp(3rem,2.2rem+3vw,4rem)] font-semibold leading-none tabular-nums tracking-[-0.04em] text-foreground" role="timer" aria-label={fill(t.timeLeft, { t: dur(Math.ceil(left / 60), t) })}>
                  {mm}
                </p>
                <p className="mt-2 flex justify-center gap-1.5" aria-hidden>
                  {Array.from({ length: focus.rounds }, (_, i) => (
                    <span key={i} className={cn('size-2 rounded-full transition-colors', i < ((round_ - 1) % focus.rounds) + (mode !== 'work' ? 1 : 0) ? 'bg-primary' : 'bg-track')} />
                  ))}
                </p>
              </div>
            </div>
          </div>
          <div className="mt-6 flex w-full items-center justify-center gap-3">
            <button type="button" aria-label={t.reset} onClick={() => (setLeft(full), setOn(false))} className={cn(iconBtn, 'size-12 border border-border sm:size-11')}>
              <Icon name="rotate-ccw" size={18} />
            </button>
            <Button
              size="lg"
              magnetic
              iconLeft={on ? 'pause' : 'play'}
              className="min-w-40"
              onClick={() => {
                setOn(!on);
                if (!on) void onAction?.('focus-start', { mode, project });
              }}
            >
              {on ? t.pause : t.play}
            </Button>
            <button type="button" aria-label={t.skip} onClick={finish} className={cn(iconBtn, 'size-12 border border-border sm:size-11')}>
              <Icon name="skip-forward" size={18} />
            </button>
          </div>
          <div className="mt-6 grid w-full grid-cols-[minmax(0,1fr)] gap-2 border-t border-border pt-5 sm:grid-cols-2">
            <label className="block min-w-0 sm:col-span-2">
              <span className="mb-1.5 block text-[12px] font-medium text-muted-foreground">{t.focusOn}</span>
              <input value={note} onChange={(e) => setNote(e.target.value)} placeholder={t.note} className={field} />
            </label>
            <Pick label={t.project} value={project} dot={ctx.color(project)} onChange={setProject} className="sm:col-span-2">
              {projects.map((x) => (
                <option key={x.id} value={x.id}>
                  {x.name}
                </option>
              ))}
            </Pick>
          </div>
        </section>
        <Card title={t.sessions} className="ttk-up [--ttk-d:120ms]" action={<span className="text-[13px] font-semibold tabular-nums text-foreground">{clock(focusMin)}</span>} bodyClassName="p-0 sm:p-0">
          <p className="border-b border-border px-4 py-3 text-[13px] text-muted-foreground sm:px-5">{fill(t.focusTotal, { t: dur(focusMin, t) })}</p>
          {log.length ? (
            <ol>
              {log.map((e) => (
                <li key={e.id} className="ttk-in flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5">
                  <span className="grid size-8 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                    <Icon name="timer" size={15} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block truncate text-[13.5px] text-foreground">{e.note}</span>
                    <span className="mt-0.5 flex items-center gap-1.5 text-[12px] text-muted-foreground">
                      <Dot color={ctx.color(e.project)} className="size-2" />
                      <span className="truncate">{ctx.project(e.project)?.name}</span>
                    </span>
                  </span>
                  <span className="text-right text-[12.5px] tabular-nums text-muted-foreground">
                    {formatTime(e.start, locale)}
                    <span className="block font-semibold text-foreground">{clock(minutesOf(e))}</span>
                  </span>
                </li>
              ))}
            </ol>
          ) : (
            <p className="px-5 py-10 text-center text-[13px] text-muted-foreground">{t.noSessions}</p>
          )}
        </Card>
      </div>
      <Live text={say} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* TimeTracker                                                         */
/* ------------------------------------------------------------------ */

/**
 * Time tracking for apps: a live timer with projects and activities, a
 * weekly timesheet you fill like a spreadsheet, entries by day to edit and
 * bill, a week summary with charts, and a focus timer with rounds.
 *   <TimeTracker variant="timer" title="Time" projects={…} entries={…} now="2026-10-21T15:30" />
 */
export function TimeTracker({ variant = 'timer', sidebar, labels, className, now = '2026-10-21T15:30', currency = 'EUR', projects = [], entries = [], ...props }: TimeTrackerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const ctx: Ctx = useMemo(() => {
    const money = new Intl.NumberFormat(tagOf(locale), { style: 'currency', currency, maximumFractionDigits: 0 });
    return { t, locale, project: (i) => projects.find((p) => p.id === i), color: (i) => PALETTE[Math.max(0, projects.findIndex((p) => p.id === i)) % PALETTE.length], money: (n) => money.format(n) };
  }, [t, locale, projects, currency]);
  const p: VP = { ...props, ctx, now, projects, entries };
  const body = (() => {
    switch (variant) {
      case 'timesheet':
        return <Timesheet {...p} />;
      case 'entries':
        return <Entries {...p} />;
      case 'week-summary':
        return <WeekSummary {...p} />;
      case 'focus':
        return <Focus {...p} />;
      default:
        return <Timer {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <Style />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <Style />
      {body}
    </Sidebar>
  );
}

Häufige Fragen

Wie installiere ich Zeiterfassung?

Mit aiw add @ai-website/time-tracker. Der Befehl legt die Datei components/ui/time-tracker.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind bar-chart, button, donut-chart, icon und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Zeiterfassung?

5 Varianten, Timer, Wochenzettel, Einträge, Wochenübersicht und Fokus-Timer. Du wählst sie mit der Prop variant, zum Beispiel variant="timesheet".

Kann ich die Texte von Zeiterfassung übersetzen?

Ja. Zeiterfassung bringt 78 eigene Texte auf Deutsch und Englisch mit, etwa „Woran arbeitest du?“, „Projekt“ und „Tätigkeit“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zeiterfassung einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert