Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Zeiterfassung
- Neue Tabelle time_entry mit started_at/ended_at/duration_seconds,
  optional todo_id/bezug, category-Enum (focus/meeting/break/admin/learning/other),
  description, day_entry_id-Verknüpfung. Partial-Unique-Index erzwingt
  max. einen aktiven Timer pro User.
- tRPC time-Router: now, start (stoppt aktiven automatisch), stop,
  listToday (Liste + Tagessumme), update, upsertManual (retroaktiv),
  delete, teamToday (personenübergreifend mit byCategory + aktivem
  Timer pro User).
- TopNav: persistente TimerPill links neben Streak. Quick-Picker mit
  heutigen ToDos, Kategorie-Auswahl und Freitext-Start. Live-Sekundenzähler.
- /heute: TimelineSection mit Edit/Delete pro Eintrag, ToDo-Bindung
  nachträglich änderbar, Block-nachtragen-Dialog. Pro ToDo-Zeile
  ▶-Start- und 🕐-Nachtrag-Button.
- /puls: TeamTimeSection mit aktiver Timer-Anzeige + Tagessumme +
  Kategorie-Breakdown pro User (refresh alle 30s, Live-Tick jede Sekunde).

Visual
- Space Grotesk als Hausschrift (selbst-gehostet, latin + latin-ext)
- Keycap-Button-Stil (Gradient + Druckeffekt) für primary/secondary/destructive
- Neue Helper-Module: format-duration, time-categories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-06-02 17:27:40 +02:00
1 parent 4f33509a0f
commit 95beb99cd4
23 files changed
+3442 -49

No files matched your search

@@ -2,6 +2,7 @@ import { NavLink, useLocation } from 'react-router';
import { clsx } from 'clsx';
import { Flame } from 'lucide-react';
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
import { TimerPill } from '../time/TimerPill';
import { voice } from '../../lib/voice';
import { useCurrentUser } from '../../hooks/useCurrentUser';
@@ -62,6 +63,7 @@ export function TopNav() {
</ul>
<div className="ml-auto flex items-center gap-3">
{me && <TimerPill />}
{me ? (
<>
<span
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { trpc } from '../../lib/trpc';
import { Card } from '../ui/Card';
import { formatDuration, formatDurationCompact } from '../../lib/format-duration';
import { TIME_CATEGORIES } from '../../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
// Tönungen pro Kategorie (für den horizontalen Balken).
const CATEGORY_TINT: Record<string, string> = {
focus: 'bg-[var(--color-ink)]',
meeting: 'bg-[var(--color-ampel-amber-fg)]',
admin: 'bg-[var(--color-ink-muted)]',
learning: 'bg-[var(--color-ampel-green-fg)]',
break: 'bg-[var(--color-ink-faint)]',
other: 'bg-[var(--color-border-strong)]',
};
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
export function TeamTimeSection() {
const team = trpc.time.teamToday.useQuery(undefined, { refetchInterval: 30_000 });
const users = trpc.user.list.useQuery();
// 1-Sekunden-Tick für lebendige aktive Timer.
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, []);
void tick;
if (team.isPending || users.isPending) return null;
const userMap = new Map((users.data ?? []).map((u) => [u.id, u.name]));
const items = (team.data ?? [])
.map((b) => ({ ...b, name: userMap.get(b.userId) ?? '—' }))
.sort((a, b) => b.totalSeconds - a.totalSeconds);
return (
<section>
<header className="mb-3 flex items-baseline justify-between">
<h2 className="text-lg font-semibold">Zeit im Team heute</h2>
<p className="text-xs text-[var(--color-ink-muted)]">
{items.length} {items.length === 1 ? 'Person trackt' : 'Personen tracken'}
</p>
</header>
{items.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Heute hat noch niemand Zeit erfasst.
</p>
</Card.Body>
</Card.Root>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((it) => (
<Card.Root key={it.userId}>
<Card.Body>
<div className="flex items-center gap-3">
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
aria-label={it.name}
>
{initials(it.name)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{it.name}</p>
<p className="text-xs text-[var(--color-ink-muted)]">
Gesamt:{' '}
<span className="font-mono">{formatDurationCompact(it.totalSeconds)}</span>
</p>
</div>
</div>
{it.active ? <ActiveLine active={it.active} /> : <IdleLine />}
<Breakdown byCategory={it.byCategory} total={it.totalSeconds} />
</Card.Body>
</Card.Root>
))}
</div>
)}
</section>
);
}
function ActiveLine({
active,
}: {
active: {
startedAt: string;
category: string;
todoTitle: string | null;
description: string | null;
};
}) {
const since = new Date(active.startedAt).getTime();
const elapsed = Math.max(0, Math.round((Date.now() - since) / 1000));
const label =
active.todoTitle || active.description || CATEGORY_LABEL[active.category] || 'läuft';
return (
<div className="mt-3 flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-ampel-red-bg)] px-2.5 py-1.5 text-xs text-[var(--color-ampel-red-fg)]">
<span className="relative flex h-1.5 w-1.5 items-center justify-center">
<span className="absolute h-1.5 w-1.5 animate-ping rounded-full bg-current opacity-60" />
<span className="relative h-1.5 w-1.5 rounded-full bg-current" />
</span>
<span className="flex-1 truncate font-medium">{label}</span>
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
</div>
);
}
function IdleLine() {
return (
<p className="mt-3 text-xs text-[var(--color-ink-faint)]">Aktuell kein Timer.</p>
);
}
function Breakdown({
byCategory,
total,
}: {
byCategory: Record<string, number>;
total: number;
}) {
if (total === 0) return null;
const ordered = TIME_CATEGORIES.map((c) => ({
key: c.value,
label: c.label,
seconds: byCategory[c.value] ?? 0,
})).filter((x) => x.seconds > 0);
return (
<div className="mt-3">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-[var(--color-border)]">
{ordered.map((o) => (
<div
key={o.key}
className={clsx('h-full', CATEGORY_TINT[o.key])}
style={{ width: `${(o.seconds / total) * 100}%` }}
title={`${o.label}: ${formatDurationCompact(o.seconds)}`}
/>
))}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--color-ink-muted)]">
{ordered.map((o) => (
<span key={o.key} className="inline-flex items-center gap-1">
<span className={clsx('h-1.5 w-1.5 rounded-full', CATEGORY_TINT[o.key])} />
{o.label} {formatDurationCompact(o.seconds)}
</span>
))}
</div>
</div>
);
}
@@ -0,0 +1,420 @@
import { useState } from 'react';
import { Pencil, Trash2, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { trpc } from '../../lib/trpc';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { formatDuration, formatDurationCompact } from '../../lib/format-duration';
import { TIME_CATEGORIES, type TimeCategory } from '../../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function toLocalInput(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalInput(s: string): Date {
return new Date(s);
}
export function TimelineSection() {
const listQuery = trpc.time.listToday.useQuery(undefined, { refetchInterval: 30_000 });
const todayQuery = trpc.dayEntry.today.useQuery();
const [manualOpen, setManualOpen] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
if (listQuery.isPending) return null;
const entries = listQuery.data?.entries ?? [];
const totalSeconds = listQuery.data?.totalSeconds ?? 0;
const todoTitles = new Map((todayQuery.data?.todos ?? []).map((t) => [t.id, t.title]));
return (
<section className="space-y-3">
<header className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Zeit heute</h2>
<p className="text-xs text-[var(--color-ink-muted)]">
Gesamt: <span className="font-mono">{formatDurationCompact(totalSeconds)}</span>
</p>
</div>
<Button size="sm" variant="secondary" onClick={() => setManualOpen(true)}>
<Plus size={14} /> Block nachtragen
</Button>
</header>
{entries.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Zeit erfasst. Starte einen Timer oder trag einen Block nach.
</p>
</Card.Body>
</Card.Root>
) : (
<Card.Root>
<Card.Body className="space-y-2">
{entries.map((e) => (
<TimelineRow
key={e.id}
entry={e}
todoTitle={e.todoId ? todoTitles.get(e.todoId) : undefined}
todos={todayQuery.data?.todos ?? []}
isEditing={editing === e.id}
onEdit={() => setEditing(e.id)}
onCancelEdit={() => setEditing(null)}
onSaved={() => setEditing(null)}
/>
))}
</Card.Body>
</Card.Root>
)}
{manualOpen && (
<ManualDialog
onClose={() => setManualOpen(false)}
todos={todayQuery.data?.todos ?? []}
/>
)}
</section>
);
}
type TimeEntryRow = {
id: string;
startedAt: string | Date;
endedAt: string | Date | null;
durationSeconds: number | null;
todoId: string | null;
category: string;
description: string | null;
};
function TimelineRow({
entry,
todoTitle,
todos,
isEditing,
onEdit,
onCancelEdit,
onSaved,
}: {
entry: TimeEntryRow;
todoTitle?: string;
todos: Array<{ id: string; title: string }>;
isEditing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onSaved: () => void;
}) {
const utils = trpc.useUtils();
const deleteMutation = trpc.time.delete.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
await utils.time.now.invalidate();
toast('Eintrag gelöscht.');
},
});
const updateMutation = trpc.time.update.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
onSaved();
toast('Aktualisiert.');
},
});
const start = new Date(entry.startedAt);
const end = entry.endedAt ? new Date(entry.endedAt) : null;
const duration = end
? (entry.durationSeconds ?? Math.round((end.getTime() - start.getTime()) / 1000))
: Math.round((Date.now() - start.getTime()) / 1000);
const label = todoTitle || entry.description || CATEGORY_LABEL[entry.category] || 'Eintrag';
const isRunning = !end;
const [editStart, setEditStart] = useState(toLocalInput(start));
const [editEnd, setEditEnd] = useState(end ? toLocalInput(end) : '');
const [editDescription, setEditDescription] = useState(entry.description ?? '');
const [editCategory, setEditCategory] = useState<TimeCategory>(entry.category as TimeCategory);
const [editTodoId, setEditTodoId] = useState<string>(entry.todoId ?? '');
if (isEditing) {
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="datetime-local"
value={editStart}
onChange={(e) => setEditStart(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
value={editEnd}
onChange={(e) => setEditEnd(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">ToDo (optional)</span>
<select
value={editTodoId}
onChange={(e) => setEditTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={editCategory}
onChange={(e) => setEditCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
</div>
<label className="mt-2 block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung</span>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
maxLength={200}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
<div className="mt-2 flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancelEdit}>
Abbrechen
</Button>
<Button
size="sm"
onClick={() => {
const startedAt = fromLocalInput(editStart).toISOString();
const endedAt = editEnd ? fromLocalInput(editEnd).toISOString() : null;
updateMutation.mutate({
id: entry.id,
startedAt,
endedAt,
todoId: editTodoId || null,
category: editCategory,
description: editDescription.trim() || null,
});
}}
disabled={updateMutation.isPending}
>
Speichern
</Button>
</div>
</div>
);
}
return (
<div className="group flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
<div className="w-28 shrink-0 font-mono text-xs text-[var(--color-ink-muted)]">
{pad(start.getHours())}:{pad(start.getMinutes())}
{end && (
<>
{' – '}
{pad(end.getHours())}:{pad(end.getMinutes())}
</>
)}
</div>
<div className="flex-1 truncate">
<p className="truncate">{label}</p>
<p className="text-xs text-[var(--color-ink-muted)]">
{CATEGORY_LABEL[entry.category]}
{entry.description && !todoTitle && entry.description !== label && ` · ${entry.description}`}
</p>
</div>
<div
className={clsx(
'shrink-0 font-mono text-sm tabular-nums',
isRunning && 'text-[var(--color-ampel-red-fg)]',
)}
>
{formatDuration(duration)}
</div>
{!isRunning && (
<div className="flex shrink-0 gap-1 opacity-0 group-hover:opacity-100">
<button
type="button"
onClick={onEdit}
aria-label="Bearbeiten"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => {
if (confirm('Eintrag wirklich löschen?')) deleteMutation.mutate({ id: entry.id });
}}
aria-label="Löschen"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
}
export function ManualDialog({
onClose,
todos,
defaultTodoId,
}: {
onClose: () => void;
todos: Array<{ id: string; title: string }>;
defaultTodoId?: string;
}) {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [startedAt, setStartedAt] = useState(toLocalInput(oneHourAgo));
const [endedAt, setEndedAt] = useState(toLocalInput(now));
const [todoId, setTodoId] = useState<string>(defaultTodoId ?? '');
const [category, setCategory] = useState<TimeCategory>('focus');
const [description, setDescription] = useState('');
const utils = trpc.useUtils();
const mutation = trpc.time.upsertManual.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
onClose();
toast('Block nachgetragen.');
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate({
startedAt: fromLocalInput(startedAt).toISOString(),
endedAt: fromLocalInput(endedAt).toISOString(),
todoId: todoId || undefined,
category,
description: description.trim() || undefined,
});
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Block nachtragen</h2>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<X size={16} />
</button>
</div>
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="datetime-local"
required
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
required
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Bezug auf ToDo (optional)</span>
<select
value={todoId}
onChange={(e) => setTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung (optional)</span>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Speichern
</Button>
</div>
</form>
</div>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
import { Play, Square, Plus } from 'lucide-react';
import { clsx } from 'clsx';
import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { formatDuration } from '../../lib/format-duration';
import { TIME_CATEGORIES } from '../../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
export function TimerPill() {
const utils = trpc.useUtils();
const nowQuery = trpc.time.now.useQuery(undefined, { refetchInterval: 30_000 });
const todayQuery = trpc.dayEntry.today.useQuery();
const [pickerOpen, setPickerOpen] = useState(false);
const [elapsed, setElapsed] = useState(0);
const pickerRef = useRef<HTMLDivElement>(null);
// Tick-Sekundenzähler nur wenn ein Timer läuft.
useEffect(() => {
if (!nowQuery.data) {
setElapsed(0);
return;
}
const start = new Date(nowQuery.data.startedAt).getTime();
const updateElapsed = () => setElapsed(Math.round((Date.now() - start) / 1000));
updateElapsed();
const id = window.setInterval(updateElapsed, 1000);
return () => window.clearInterval(id);
}, [nowQuery.data]);
useEffect(() => {
if (!pickerOpen) return undefined;
function onClick(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setPickerOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setPickerOpen(false);
}
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [pickerOpen]);
const stopMutation = trpc.time.stop.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
toast('Timer gestoppt.');
},
});
const startMutation = trpc.time.start.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
setPickerOpen(false);
toast('Timer läuft.');
},
});
const active = nowQuery.data;
const title = active
? active.description ||
(active.todoId
? todayQuery.data?.todos.find((t) => t.id === active.todoId)?.title ?? 'ToDo'
: CATEGORY_LABEL[active.category] ?? 'Timer')
: null;
return (
<div className="relative" ref={pickerRef}>
{active ? (
<button
type="button"
onClick={() => stopMutation.mutate()}
className="group inline-flex items-center gap-2 rounded-full bg-[var(--color-ampel-red-bg)] px-2.5 py-1 text-xs font-medium text-[var(--color-ampel-red-fg)] hover:bg-red-200"
title="Stop"
>
<span className="flex h-1.5 w-1.5 items-center justify-center">
<span className="absolute h-1.5 w-1.5 animate-ping rounded-full bg-[var(--color-ampel-red-fg)] opacity-60" />
<span className="relative h-1.5 w-1.5 rounded-full bg-[var(--color-ampel-red-fg)]" />
</span>
<span className="max-w-[180px] truncate">{title}</span>
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
<Square size={10} fill="currentColor" className="opacity-0 group-hover:opacity-100" />
</button>
) : (
<button
type="button"
onClick={() => setPickerOpen((o) => !o)}
aria-expanded={pickerOpen}
aria-haspopup="menu"
className="inline-flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-xs font-medium text-[var(--color-ink-muted)] hover:bg-black/10 hover:text-[var(--color-ink)]"
title="Timer starten"
>
<Play size={10} fill="currentColor" />
<span>Start</span>
</button>
)}
{pickerOpen && !active && (
<QuickPicker
todos={(todayQuery.data?.todos ?? []).filter((t) => t.status === 'open')}
onClose={() => setPickerOpen(false)}
onStart={(payload) => startMutation.mutate(payload)}
pending={startMutation.isPending}
/>
)}
</div>
);
}
function QuickPicker({
todos,
onClose,
onStart,
pending,
}: {
todos: Array<{ id: string; title: string }>;
onClose: () => void;
onStart: (input: { todoId?: string; category: 'focus' | 'meeting' | 'admin' | 'learning' | 'break' | 'other'; description?: string }) => void;
pending: boolean;
}) {
const [description, setDescription] = useState('');
return (
<div
role="menu"
className="absolute right-0 top-full z-40 mt-2 w-[320px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-elevated)] p-3 shadow-[var(--shadow-lg)]"
>
<p className="px-1 text-xs font-medium text-[var(--color-ink-muted)]">Was machst du jetzt?</p>
{todos.length > 0 && (
<div className="mt-2 space-y-0.5">
<p className="px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
Deine ToDos heute
</p>
{todos.map((t) => (
<button
key={t.id}
type="button"
onClick={() => onStart({ todoId: t.id, category: 'focus' })}
disabled={pending}
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-sm hover:bg-black/5"
>
<Play size={10} className="text-[var(--color-ink-muted)]" />
<span className="truncate">{t.title}</span>
</button>
))}
</div>
)}
<div className="mt-3 space-y-0.5">
<p className="px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
Andere Tätigkeit
</p>
{TIME_CATEGORIES.filter((c) => c.value !== 'focus').map((c) => (
<button
key={c.value}
type="button"
onClick={() => onStart({ category: c.value, description: description.trim() || undefined })}
disabled={pending}
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-sm hover:bg-black/5"
>
<Play size={10} className="text-[var(--color-ink-muted)]" />
<span>{c.label}</span>
</button>
))}
</div>
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
<label className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
Mit freiem Text
</label>
<div className="mt-1 flex gap-1.5">
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="z. B. Kunde Schmidt — Call"
maxLength={200}
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
<button
type="button"
disabled={pending || !description.trim()}
onClick={() => onStart({ category: 'focus', description: description.trim() })}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] bg-[var(--color-brand)] px-3 py-1.5 text-xs font-medium text-[var(--color-brand-fg)] disabled:opacity-50"
>
<Plus size={12} /> Start
</button>
</div>
</div>
<button
type="button"
onClick={onClose}
className="mt-2 block w-full rounded-[var(--radius-md)] px-2 py-1 text-xs text-[var(--color-ink-faint)] hover:bg-black/5"
>
Abbrechen
</button>
</div>
);
}
+11 -8
View File
@@ -9,20 +9,23 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: Size;
}
// Buttons im ToolCTRL-Website-Stil (Keycap-Optik). Die eigentliche Optik kommt
// aus den .btn-keycap-* Klassen in globals.css (Gradient-Fläche, Tastenkante,
// Druckeffekt). Die Tailwind-Größenklassen liefern nur die Maße.
const base =
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex items-center justify-center gap-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed';
const variantStyles: Record<Variant, string> = {
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
primary: 'btn-keycap btn-keycap-solid',
secondary: 'btn-keycap btn-keycap-ghost',
ghost: 'rounded-md text-[var(--color-ink)] hover:bg-black/5 transition-colors',
destructive: 'btn-keycap btn-keycap-destructive',
};
const sizeStyles: Record<Size, string> = {
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
sm: 'h-7 px-3 text-xs',
md: 'h-9 px-4 text-sm',
lg: 'h-11 px-6 text-base',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
+2 -1
View File
@@ -2,10 +2,11 @@ import { clsx } from 'clsx';
import type { HTMLAttributes } from 'react';
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
// Scharfe Kanten, Haarlinien-Rahmen ohne Schatten — ToolCTRL-Website-Stil.
return (
<div
className={clsx(
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
'bg-[var(--color-surface)] border border-[var(--color-border)]',
className,
)}
{...rest}