feat(zeit): Pause-Button (ENTWICKLUN-15)
ci / quality (push) Failing after 35s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 46s

Pause-Button in der Timer-Pill (sichtbar im Leerlauf und während ein Task
läuft): ein Klick stoppt den laufenden Task und startet sofort eine Pause
(break-Eintrag) — bis man wieder eine Aufgabe aktiviert. Läuft eine Pause,
zeigt die Pill sie mit Live-Zähler; ein Panel erlaubt optional einen Grund
nachzutragen (z. B. Bürofahrt, Essen), „Weiter mit Aufgabe" oder „Pause
beenden". Grund ist optional — Pause kann einfach Pause sein.

Reine Frontend-Änderung (nutzt time.start mit category 'break'), keine Migration.

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 17:11:23 +02:00
1 parent 22f79460b2
commit 02bd682fa5
1 file changed
+171 -28
+171 -28
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Play, Square, Plus } from 'lucide-react';
import { Play, Square, Plus, Pause } from 'lucide-react';
import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { formatDuration } from '../../lib/format-duration';
@@ -14,6 +14,7 @@ export function TimerPill() {
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 pickerRef = useRef<HTMLDivElement>(null);
@@ -31,14 +32,18 @@ export function TimerPill() {
}, [nowQuery.data]);
useEffect(() => {
if (!pickerOpen) return undefined;
if (!pickerOpen && !pausePanelOpen) return undefined;
function onClick(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setPickerOpen(false);
setPausePanelOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setPickerOpen(false);
if (e.key === 'Escape') {
setPickerOpen(false);
setPausePanelOpen(false);
}
}
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
@@ -46,13 +51,12 @@ export function TimerPill() {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [pickerOpen]);
}, [pickerOpen, pausePanelOpen]);
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({
@@ -63,8 +67,17 @@ export function TimerPill() {
toast('Timer läuft.');
},
});
// Pause = break-Eintrag; stoppt automatisch einen laufenden Task.
const pauseMutation = trpc.time.start.useMutation({
onSuccess: async () => {
await utils.time.now.invalidate();
await utils.time.listToday.invalidate();
toast('Pause läuft.');
},
});
const active = nowQuery.data;
const isPause = active?.category === 'break';
const title = active
? active.description ||
(active.todoId
@@ -72,38 +85,66 @@ export function TimerPill() {
: CATEGORY_LABEL[active.category] ?? 'Timer')
: null;
function startPause() {
setPickerOpen(false);
pauseMutation.mutate({ category: 'break' });
}
return (
<div className="relative" ref={pickerRef}>
{active ? (
<div className="relative flex items-center gap-1.5" ref={pickerRef}>
{active && isPause ? (
// --- Pause läuft ---
<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"
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"
>
<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>
<Pause size={10} fill="currentColor" />
<span className="max-w-[160px] truncate">{active.description || 'Pause'}</span>
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
<Square size={10} fill="currentColor" className="opacity-0 group-hover:opacity-100" />
</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} />
</>
) : (
<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>
// --- 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} />
</>
)}
{pickerOpen && !active && (
{pickerOpen && (!active || isPause) && (
<QuickPicker
todos={(todayQuery.data?.todos ?? []).filter((t) => t.status === 'open')}
onClose={() => setPickerOpen(false)}
@@ -111,6 +152,108 @@ export function TimerPill() {
pending={startMutation.isPending}
/>
)}
{pausePanelOpen && active && isPause && (
<PausePanel
entryId={active.id}
currentGrund={active.description ?? ''}
onResume={() => {
setPausePanelOpen(false);
setPickerOpen(true);
}}
onEnd={() => {
setPausePanelOpen(false);
stopMutation.mutate(undefined, { onSuccess: () => toast('Pause beendet.') });
}}
onSavedGrund={() => setPausePanelOpen(false)}
/>
)}
</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,
}: {
entryId: string;
currentGrund: string;
onResume: () => void;
onEnd: () => void;
onSavedGrund: () => 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();
},
});
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)]"
>
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>
</div>
);
}