feat(zeit): mehrere Timer parallel (ENTWICKLUN-6)
ci / quality (push) Failing after 44s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

Mehrere Tasks gleichzeitig tracken. Unique-Index "ein aktiver Timer pro User"
entfernt (Migration 0013); time.start stoppt den laufenden nicht mehr, sondern
läuft parallel.

- time.now liefert jetzt ALLE laufenden Timer (Array) inkl. ToDo-Titel.
- time.stop({id}) stoppt einen bestimmten Timer; time.stopAll stoppt alle.
- time.pause stoppt ALLE laufenden Tasks und startet eine Pause (passt zum
  Pause-Ticket „alle laufenden Tasks pausiert").
- TimerPill: zeigt Anzahl laufender Timer; Panel listet alle mit Live-Zähler +
  einzelnem Stop, „Weiteren Task starten", Grund-Nachtrag für Pausen.
- Heute-ToDo-Zeile: Start/Stop pro ToDo unabhängig (Array-basiert).

Zusammenspiel: Team-Timeline zeigt parallele Stränge, „Gesamt"-Zeit zählt
Überlappung weiterhin nur einmal (Wall-Clock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 18:28:48 +02:00
1 parent 66adc49e2c
commit 9942cd8e88
8 files changed
+3535 -242

No files matched your search

+151 -178
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Play, Square, Plus, Pause } from 'lucide-react';
import { clsx } from 'clsx';
import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { formatDuration } from '../../lib/format-duration';
@@ -8,41 +9,61 @@ import { TIME_CATEGORIES } from '../../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
const CATEGORY_DOT: 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-ampel-amber-fg)]',
other: 'bg-[var(--color-border-strong)]',
};
type Active = {
id: string;
startedAt: string | Date;
category: string;
description: string | null;
todoId: string | null;
todoTitle: string | null;
};
function labelOf(a: Active): string {
return a.description || a.todoTitle || CATEGORY_LABEL[a.category] || 'Timer';
}
function elapsedOf(a: Active): number {
return Math.max(0, Math.round((Date.now() - new Date(a.startedAt).getTime()) / 1000));
}
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 [pausePanelOpen, setPausePanelOpen] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [panelOpen, setPanelOpen] = useState(false);
const [, setTick] = useState(0);
const pickerRef = useRef<HTMLDivElement>(null);
// Tick-Sekundenzähler nur wenn ein Timer läuft.
const active: Active[] = nowQuery.data ?? [];
// Sekunden-Tick nur wenn etwas 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);
if (active.length === 0) return undefined;
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, [nowQuery.data]);
}, [active.length]);
useEffect(() => {
if (!pickerOpen && !pausePanelOpen) return undefined;
if (!pickerOpen && !panelOpen) return undefined;
function onClick(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setPickerOpen(false);
setPausePanelOpen(false);
setPanelOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
setPickerOpen(false);
setPausePanelOpen(false);
setPanelOpen(false);
}
}
document.addEventListener('mousedown', onClick);
@@ -51,101 +72,76 @@ export function TimerPill() {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [pickerOpen, pausePanelOpen]);
}, [pickerOpen, panelOpen]);
const stopMutation = trpc.time.stop.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
},
});
const invalidate = async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
};
const startMutation = trpc.time.start.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
await invalidate();
setPickerOpen(false);
toast('Timer läuft.');
},
});
// Pause = break-Eintrag; stoppt automatisch einen laufenden Task.
const pauseMutation = trpc.time.start.useMutation({
const pauseMutation = trpc.time.pause.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
await invalidate();
setPickerOpen(false);
setPanelOpen(false);
toast('Pause läuft.');
},
});
const active = nowQuery.data;
const isPause = active?.category === 'break';
const title = active
? active.description ||
active.todoTitle ||
(active.todoId
? todayQuery.data?.todos.find((t) => t.id === active.todoId)?.title ?? 'ToDo'
: CATEGORY_LABEL[active.category] ?? 'Timer')
: null;
function startPause() {
setPickerOpen(false);
pauseMutation.mutate({ category: 'break' });
}
const count = active.length;
const longest = count > 0 ? Math.max(...active.map(elapsedOf)) : 0;
return (
<div className="relative flex items-center gap-1.5" ref={pickerRef}>
{active && isPause ? (
// --- Pause läuft ---
{count > 0 ? (
<button
type="button"
onClick={() => setPausePanelOpen((o) => !o)}
aria-expanded={pausePanelOpen}
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-ampel-amber-bg)] px-2.5 py-1 text-xs font-medium text-[var(--color-ampel-amber-fg)] hover:opacity-90"
title="Pause-Optionen"
onClick={() => setPanelOpen((o) => !o)}
aria-expanded={panelOpen}
className="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={count === 1 ? 'Timer' : `${count} Timer laufen`}
>
<Pause size={10} fill="currentColor" />
<span className="max-w-[160px] truncate">{active.description || 'Pause'}</span>
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
<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-[160px] truncate">
{count === 1 ? labelOf(active[0]!) : `${count} Timer`}
</span>
<span className="font-mono tabular-nums">{formatDuration(longest)}</span>
</button>
) : active ? (
// --- Task läuft ---
<>
<button
type="button"
onClick={() =>
stopMutation.mutate(undefined, { onSuccess: () => toast('Timer gestoppt.') })
}
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-[160px] 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>
<PauseButton onClick={startPause} pending={pauseMutation.isPending} />
</>
) : (
// --- Idle ---
<>
<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>
<PauseButton onClick={startPause} pending={pauseMutation.isPending} />
</>
<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 || isPause) && (
<button
type="button"
onClick={() => pauseMutation.mutate({})}
disabled={pauseMutation.isPending}
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)] disabled:opacity-50"
title="Pause — stoppt alle laufenden Tasks und trägt eine Pause ein"
>
<Pause size={10} fill="currentColor" />
<span>Pause</span>
</button>
{pickerOpen && (
<QuickPicker
todos={(todayQuery.data?.todos ?? []).filter((t) => t.status === 'open')}
onClose={() => setPickerOpen(false)}
@@ -154,107 +150,80 @@ export function TimerPill() {
/>
)}
{pausePanelOpen && active && isPause && (
<PausePanel
entryId={active.id}
currentGrund={active.description ?? ''}
onResume={() => {
setPausePanelOpen(false);
{panelOpen && count > 0 && (
<TimersPanel
active={active}
onAdd={() => {
setPanelOpen(false);
setPickerOpen(true);
}}
onEnd={() => {
setPausePanelOpen(false);
stopMutation.mutate(undefined, { onSuccess: () => toast('Pause beendet.') });
}}
onSavedGrund={() => setPausePanelOpen(false)}
onChanged={invalidate}
/>
)}
</div>
);
}
function PauseButton({ onClick, pending }: { onClick: () => void; pending: boolean }) {
return (
<button
type="button"
onClick={onClick}
disabled={pending}
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)] disabled:opacity-50"
title="Pause — stoppt den laufenden Task und trägt eine Pause ein"
>
<Pause size={10} fill="currentColor" />
<span>Pause</span>
</button>
);
}
function PausePanel({
entryId,
currentGrund,
onResume,
onEnd,
onSavedGrund,
function TimersPanel({
active,
onAdd,
onChanged,
}: {
entryId: string;
currentGrund: string;
onResume: () => void;
onEnd: () => void;
onSavedGrund: () => void;
active: Active[];
onAdd: () => void;
onChanged: () => Promise<void>;
}) {
const utils = trpc.useUtils();
const [grund, setGrund] = useState(currentGrund);
const update = trpc.time.update.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
onSavedGrund();
},
});
const stopMutation = trpc.time.stop.useMutation({ onSuccess: onChanged });
const updateMutation = trpc.time.update.useMutation({ onSuccess: onChanged });
return (
<div className="absolute right-0 top-full z-40 mt-2 w-[280px] 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)]">Pause läuft</p>
<label
htmlFor="pause-grund"
className="mt-2 block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]"
<div 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)]">
Läuft gerade ({active.length})
</p>
<div className="mt-2 space-y-1.5">
{active.map((a) => (
<div key={a.id} className="rounded-[var(--radius-md)] px-1 py-1">
<div className="flex items-center gap-2">
<span className={clsx('h-2 w-2 shrink-0 rounded-full', CATEGORY_DOT[a.category])} />
<span className="flex-1 truncate text-sm">{labelOf(a)}</span>
<span className="font-mono text-xs tabular-nums text-[var(--color-ink-muted)]">
{formatDuration(elapsedOf(a))}
</span>
<button
type="button"
onClick={() => stopMutation.mutate({ id: a.id })}
aria-label="Stoppen"
title="Stoppen"
className="inline-flex h-6 w-6 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-ink-muted)] hover:bg-black/5 hover:text-[var(--color-ink)]"
>
<Square size={12} fill="currentColor" />
</button>
</div>
{a.category === 'break' && (
<input
type="text"
defaultValue={a.description ?? ''}
placeholder="Grund (optional) …"
maxLength={200}
onBlur={(e) => {
const v = e.target.value.trim();
if ((a.description ?? '') !== v) {
updateMutation.mutate({ id: a.id, description: v || null });
}
}}
className="mt-1 ml-4 w-[calc(100%-1rem)] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-xs"
/>
)}
</div>
))}
</div>
<button
type="button"
onClick={onAdd}
className="mt-2 flex w-full items-center gap-2 border-t border-[var(--color-border)] px-1 pt-2 text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
>
Grund (optional)
</label>
<div className="mt-1 flex gap-1.5">
<input
id="pause-grund"
type="text"
value={grund}
onChange={(e) => setGrund(e.target.value)}
placeholder="z. B. Bürofahrt, Essen …"
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={update.isPending}
onClick={() => update.mutate({ id: entryId, description: grund.trim() || null })}
className="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"
>
Speichern
</button>
</div>
<div className="mt-3 space-y-1 border-t border-[var(--color-border)] pt-2">
<button
type="button"
onClick={onResume}
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={12} className="text-[var(--color-ink-muted)]" /> Weiter mit Aufgabe
</button>
<button
type="button"
onClick={onEnd}
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"
>
<Square size={12} className="text-[var(--color-ink-muted)]" /> Pause beenden
</button>
</div>
<Plus size={12} /> Weiteren Task starten
</button>
</div>
);
}
@@ -267,7 +236,11 @@ function QuickPicker({
}: {
todos: Array<{ id: string; title: string }>;
onClose: () => void;
onStart: (input: { todoId?: string; category: 'focus' | 'meeting' | 'admin' | 'learning' | 'break' | 'other'; description?: string }) => void;
onStart: (input: {
todoId?: string;
category: 'focus' | 'meeting' | 'admin' | 'learning' | 'break' | 'other';
description?: string;
}) => void;
pending: boolean;
}) {
const [description, setDescription] = useState('');
+4 -3
View File
@@ -204,7 +204,8 @@ function TodoRow({
await utils.time.listToday.invalidate();
},
});
const isRunningHere = now.data?.todoId === todo.id;
const activeHere = now.data?.find((a) => a.todoId === todo.id);
const isRunningHere = !!activeHere;
const dotClass =
todo.status === 'done'
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
@@ -232,8 +233,8 @@ function TodoRow({
<button
type="button"
onClick={() =>
isRunningHere
? stopMutation.mutate()
activeHere
? stopMutation.mutate({ id: activeHere.id })
: startMutation.mutate({ todoId: todo.id, category: 'focus' })
}
aria-label={isRunningHere ? 'Timer stoppen' : 'Timer für diesen ToDo starten'}