feat(zeit): Pause-Button (ENTWICKLUN-15)
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:
1 parent
22f79460b2
commit
02bd682fa5
1 file changed
+171
-28
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user