Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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}
|
||||
|
||||
Reference in new issue
Block a user