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.
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
Details
Was Zeiterfassung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Aufgaben und Planung
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/time-tracker.tsx
- Bringt mit
- utilskitbar-chartbuttondonut-charticonsidebar
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 78 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TimeTrackerProps aus components/ui/time-tracker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalTimeTrackerVarianttimertimesheetentriesweek-summaryfocus
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringprojectsoptionalTimeProject[]tasksoptionalstring[]Kinds of work to pick from
entriesoptionalTimeEntry[]runningoptional{ project: string; task?: string; note?: string; start: string } | nulltimer: a timer that already runs, started today at HH:MM
nowoptionalstringYYYY-MM-DDTHH:MM that counts as now
rowsoptionalTimesheetRow[]timesheet: the rows of the current week
goaloptionalnumberHours a week
dailyGoaloptionalnumberHours a day
currencyoptionalstringCurrency 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| note | Englisch What are you working on? | Deutsch Woran arbeitest du? |
| project | Englisch Project | Deutsch Projekt |
| task | Englisch Activity | Deutsch Tätigkeit |
| billable | Englisch Billable | Deutsch Abrechenbar |
| notBillable | Englisch Not billable | Deutsch Nicht abrechenbar |
| start | Englisch Start timer | Deutsch Timer starten |
| stop | Englisch Stop timer | Deutsch Timer stoppen |
| running | Englisch Running since {t} | Deutsch Läuft seit {t} |
| idle | Englisch Ready when you are | Deutsch Bereit, wenn du es bist |
| started | Englisch Timer started for {p}. | Deutsch Timer für {p} gestartet. |
| stopped | Englisch {d} logged on {p}. | Deutsch {d} auf {p} gebucht. |
| today | Englisch Today | Deutsch Heute |
| thisWeek | Englisch This week | Deutsch Diese Woche |
| weekOf | Englisch {h} of {g} h this week | Deutsch {h} von {g} Std. diese Woche |
| resume | Englisch Continue {p} | Deutsch {p} fortsetzen |
| remove | Englisch Delete entry {p} | Deutsch Eintrag {p} löschen |
| removed | Englisch Entry deleted. | Deutsch Eintrag gelöscht. |
| undo | Englisch Undo | Deutsch Rückgängig |
| noEntries | Englisch No time logged yet. Start the timer and it shows up here. | Deutsch Noch keine Zeit erfasst. Starte den Timer, dann steht sie hier. |
| total | Englisch Total | Deutsch Summe |
| day | Englisch Day | Deutsch Tag |
| week | Englisch Week | Deutsch Woche |
| prevWeek | Englisch Previous week | Deutsch Vorige Woche |
| nextWeek | Englisch Next week | Deutsch Nächste Woche |
| cell | Englisch {p} on {d} | Deutsch {p} am {d} |
| rowTotal | Englisch Total for {p} | Deutsch Summe für {p} |
| addRow | Englisch Add row | Deutsch Zeile hinzufügen |
| removeRow | Englisch Remove row {p} | Deutsch Zeile {p} entfernen |
| submit | Englisch Submit week | Deutsch Woche einreichen |
| submitting | Englisch Submitting… | Deutsch Wird eingereicht… |
| submitted | Englisch Submitted for approval | Deutsch Zur Freigabe eingereicht |
| submittedSay | Englisch Week submitted. Your manager gets a note. | Deutsch Woche eingereicht. Deine Leitung bekommt Bescheid. |
| copyLast | Englisch Copy rows from last week | Deutsch Zeilen der Vorwoche übernehmen |
| emptyWeek | Englisch Nothing logged in this week yet. | Deutsch In dieser Woche ist noch nichts erfasst. |
| goalDay | Englisch {h} of {g} h | Deutsch {h} von {g} Std. |
| more | Englisch Add 15 minutes to {p} | Deutsch 15 Minuten mehr für {p} |
| less | Englisch Take 15 minutes off {p} | Deutsch 15 Minuten weniger für {p} |
| hoursIn | Englisch Hours | Deutsch Stunden |
| allProjects | Englisch All projects | Deutsch Alle Projekte |
| onlyBillable | Englisch Billable only | Deutsch Nur abrechenbar |
| edit | Englisch Edit entry {p} | Deutsch Eintrag {p} bearbeiten |
| save | Englisch Save | Deutsch Speichern |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| saved | Englisch Entry saved. | Deutsch Eintrag gespeichert. |
| from | Englisch From | Deutsch Von |
| to | Englisch To | Deutsch Bis |
| amount | Englisch Billable amount | Deutsch Abrechenbarer Betrag |
| billableShare | Englisch Billable share | Deutsch Abrechenbarer Anteil |
| totalHours | Englisch Hours logged | Deutsch Erfasste Stunden |
| avgDay | Englisch Average a day | Deutsch Schnitt am Tag |
| toggleBillable | Englisch Billable, {p} | Deutsch Abrechenbar, {p} |
| byDay | Englisch Hours by day | Deutsch Stunden je Tag |
| byProject | Englisch By project | Deutsch Nach Projekt |
| share | Englisch Share | Deutsch Anteil |
| fromProjects | Englisch From {n} projects | Deutsch Aus {n} Projekten |
| goal | Englisch Weekly goal | Deutsch Wochenziel |
| goalLeft | Englisch {h} h to go | Deutsch noch {h} Std. |
| goalMet | Englisch Goal reached | Deutsch Ziel erreicht |
| lastWeek | Englisch Last week | Deutsch Vorwoche |
| focus | Englisch Focus | Deutsch Fokus |
| shortBreak | Englisch Short break | Deutsch Kurze Pause |
| longBreak | Englisch Long break | Deutsch Lange Pause |
| mode | Englisch Mode | Deutsch Modus |
| play | Englisch Start | Deutsch Starten |
| pause | Englisch Pause | Deutsch Pausieren |
| reset | Englisch Reset | Deutsch Zurücksetzen |
| skip | Englisch Skip to next | Deutsch Weiter zum Nächsten |
| round | Englisch Round {n} of {m} | Deutsch Runde {n} von {m} |
| sessions | Englisch Focus sessions today | Deutsch Fokusrunden heute |
| focusTotal | Englisch {t} of focus today | Deutsch {t} Fokus heute |
| focusDone | Englisch Focus round done. Time for a break. | Deutsch Fokusrunde geschafft. Zeit für eine Pause. |
| breakDone | Englisch Break over. Ready for the next round. | Deutsch Pause vorbei. Bereit für die nächste Runde. |
| noSessions | Englisch Your finished rounds land here. | Deutsch Hier landen deine fertigen Runden. |
| focusOn | Englisch Working on | Deutsch Woran |
| timeLeft | Englisch {t} left | Deutsch noch {t} |
| h | Englisch {h} h | Deutsch {h} Std. |
| m | Englisch {m} min | Deutsch {m} Min. |
| hm | Englisch {h} h {m} min | Deutsch {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
'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.