Skip to content
UI Kit
Accent color

Time tracking UI

Time tracking with a live timer, a weekly timesheet you fill like a spreadsheet, entries by day to edit and bill, a week summary with charts and a focus timer.

5 variantsOne of 4 components in Tasks and planning

Every hour counted where it happens

Time tracking works when it takes seconds, start, note, stop. The five screens here cover the running timer, the week on one sheet, the entries to bill, the look back at the week and focused work in rounds.

Timer runs with note, project, activity and a billable switch, lists today's entries to continue or delete and sets the week against its goal. Timesheet lays projects across days with totals per row, day and week, the arrow keys move like in a spreadsheet, and phones get steppers. Entries groups by day with hours and amount on top, filters by project and billable, edits in place and deletes with undo. Week summary sets hours against the goal with billable share, amount and daily average, stacked bars per day, the split by project and last week to compare. Focus timer turns rounds and breaks on a ring with a minute scale and logs every finished round.

Put the timer where people already are, in the header or the sidebar of your app, a separate page gets forgotten. Show billable time and amount together, then nobody has to calculate. Submitting the timesheet once a week keeps corrections small.

Variants

TimerNew in 1.2

A running timer with note, project, activity and billable switch, today's entries to continue or delete, and the week against its goal.

TimesheetNew in 1.2

Projects by days with totals per row, day and week, arrow keys like a spreadsheet, week switching, submitting and steppers on phones.

EntriesNew in 1.2

Entries grouped by day with totals, hours and amount up top, filters by project and billable, edit in place and delete with undo.

Week summaryNew in 1.2

Hours against the goal, billable share, amount and daily average, stacked bars per day, the split by project and last week to compare.

Focus timerNew in 1.2

Focus rounds and breaks on a ring with a minute scale, start, pause, reset and skip, what you work on, and every finished round logged.

Details

What Time tracker brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/time-tracker.tsx
npm packages
None
Languages
English and German, 78 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The TimeTrackerProps interface from components/ui/time-tracker.tsx, with the comments from the code.

  • variantoptional
    TimeTrackerVariant

    timertimesheetentriesweek-summaryfocus

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

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

  • titlerequired
    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.

Built-in texts

Time tracker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 78 texts
noteEnglish What are you working on?German Woran arbeitest du?
projectEnglish ProjectGerman Projekt
taskEnglish ActivityGerman Tätigkeit
billableEnglish BillableGerman Abrechenbar
notBillableEnglish Not billableGerman Nicht abrechenbar
startEnglish Start timerGerman Timer starten
stopEnglish Stop timerGerman Timer stoppen
runningEnglish Running since {t}German Läuft seit {t}
idleEnglish Ready when you areGerman Bereit, wenn du es bist
startedEnglish Timer started for {p}.German Timer für {p} gestartet.
stoppedEnglish {d} logged on {p}.German {d} auf {p} gebucht.
todayEnglish TodayGerman Heute
thisWeekEnglish This weekGerman Diese Woche
weekOfEnglish {h} of {g} h this weekGerman {h} von {g} Std. diese Woche
resumeEnglish Continue {p}German {p} fortsetzen
removeEnglish Delete entry {p}German Eintrag {p} löschen
removedEnglish Entry deleted.German Eintrag gelöscht.
undoEnglish UndoGerman Rückgängig
noEntriesEnglish No time logged yet. Start the timer and it shows up here.German Noch keine Zeit erfasst. Starte den Timer, dann steht sie hier.
totalEnglish TotalGerman Summe
dayEnglish DayGerman Tag
weekEnglish WeekGerman Woche
prevWeekEnglish Previous weekGerman Vorige Woche
nextWeekEnglish Next weekGerman Nächste Woche
cellEnglish {p} on {d}German {p} am {d}
rowTotalEnglish Total for {p}German Summe für {p}
addRowEnglish Add rowGerman Zeile hinzufügen
removeRowEnglish Remove row {p}German Zeile {p} entfernen
submitEnglish Submit weekGerman Woche einreichen
submittingEnglish Submitting…German Wird eingereicht…
submittedEnglish Submitted for approvalGerman Zur Freigabe eingereicht
submittedSayEnglish Week submitted. Your manager gets a note.German Woche eingereicht. Deine Leitung bekommt Bescheid.
copyLastEnglish Copy rows from last weekGerman Zeilen der Vorwoche übernehmen
emptyWeekEnglish Nothing logged in this week yet.German In dieser Woche ist noch nichts erfasst.
goalDayEnglish {h} of {g} hGerman {h} von {g} Std.
moreEnglish Add 15 minutes to {p}German 15 Minuten mehr für {p}
lessEnglish Take 15 minutes off {p}German 15 Minuten weniger für {p}
hoursInEnglish HoursGerman Stunden
allProjectsEnglish All projectsGerman Alle Projekte
onlyBillableEnglish Billable onlyGerman Nur abrechenbar
editEnglish Edit entry {p}German Eintrag {p} bearbeiten
saveEnglish SaveGerman Speichern
cancelEnglish CancelGerman Abbrechen
savedEnglish Entry saved.German Eintrag gespeichert.
fromEnglish FromGerman Von
toEnglish ToGerman Bis
amountEnglish Billable amountGerman Abrechenbarer Betrag
billableShareEnglish Billable shareGerman Abrechenbarer Anteil
totalHoursEnglish Hours loggedGerman Erfasste Stunden
avgDayEnglish Average a dayGerman Schnitt am Tag
toggleBillableEnglish Billable, {p}German Abrechenbar, {p}
byDayEnglish Hours by dayGerman Stunden je Tag
byProjectEnglish By projectGerman Nach Projekt
shareEnglish ShareGerman Anteil
fromProjectsEnglish From {n} projectsGerman Aus {n} Projekten
goalEnglish Weekly goalGerman Wochenziel
goalLeftEnglish {h} h to goGerman noch {h} Std.
goalMetEnglish Goal reachedGerman Ziel erreicht
lastWeekEnglish Last weekGerman Vorwoche
focusEnglish FocusGerman Fokus
shortBreakEnglish Short breakGerman Kurze Pause
longBreakEnglish Long breakGerman Lange Pause
modeEnglish ModeGerman Modus
playEnglish StartGerman Starten
pauseEnglish PauseGerman Pausieren
resetEnglish ResetGerman Zurücksetzen
skipEnglish Skip to nextGerman Weiter zum Nächsten
roundEnglish Round {n} of {m}German Runde {n} von {m}
sessionsEnglish Focus sessions todayGerman Fokusrunden heute
focusTotalEnglish {t} of focus todayGerman {t} Fokus heute
focusDoneEnglish Focus round done. Time for a break.German Fokusrunde geschafft. Zeit für eine Pause.
breakDoneEnglish Break over. Ready for the next round.German Pause vorbei. Bereit für die nächste Runde.
noSessionsEnglish Your finished rounds land here.German Hier landen deine fertigen Runden.
focusOnEnglish Working onGerman Woran
timeLeftEnglish {t} leftGerman noch {t}
hEnglish {h} hGerman {h} Std.
mEnglish {m} minGerman {m} Min.
hmEnglish {h} h {m} minGerman {h} Std. {m} Min.

Accessibility

Read from the source of the component.

  • Time tracker handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles label, radio, radiogroup, status and timer.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,505 lines
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>
  );
}

Frequent questions

How do I install Time tracker?

With aiw add @ai-website/time-tracker. The command puts components/ui/time-tracker.tsx into your project, the code is yours from then on. It brings along bar-chart, button, donut-chart, icon and sidebar. It needs no further npm packages.

Which variants does Time tracker have?

5 variants, Timer, Timesheet, Entries, Week summary and Focus timer. You pick one with the variant prop, for example variant="timesheet".

Can I translate the texts of Time tracker?

Yes. Time tracker carries 78 texts of its own in English and German, such as “What are you working on?”, “Project” and “Activity”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Time tracker?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Time tracker with aiw_components and install it with aiw_add @ai-website/time-tracker. Name the variant in your prompt, for example variant="timer", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard