feat(zeit): mehrere Timer parallel (ENTWICKLUN-6)
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:
1 parent
66adc49e2c
commit
9942cd8e88
8 files changed
+3535
-242
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, isNull, asc, desc, sql, inArray } from 'drizzle-orm';
|
||||
import { timeEntry, dayEntry, todo, user, type TimeEntry } from '@cockpit/db';
|
||||
import { timeEntry, dayEntry, todo, user, type TimeEntry, type Db } from '@cockpit/db';
|
||||
import { TimeEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso, berlinDayStartMs } from '../lib/werktag.js';
|
||||
@@ -43,32 +43,27 @@ function unionSeconds(intervals: Array<readonly [number, number]>): number {
|
||||
return Math.round(total / 1000);
|
||||
}
|
||||
|
||||
async function stopCurrent(
|
||||
tx: import('@cockpit/db').Db,
|
||||
userId: string,
|
||||
now: Date,
|
||||
): Promise<TimeEntry | null> {
|
||||
const active = (
|
||||
await tx
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(and(eq(timeEntry.userId, userId), isNull(timeEntry.endedAt)))
|
||||
.limit(1)
|
||||
)[0];
|
||||
if (!active) return null;
|
||||
const durationSeconds = Math.max(0, Math.round((now.getTime() - active.startedAt.getTime()) / 1000));
|
||||
const [stopped] = await tx
|
||||
async function stopOneEntry(db: Db, entry: TimeEntry, now: Date): Promise<TimeEntry | null> {
|
||||
const durationSeconds = Math.max(0, Math.round((now.getTime() - entry.startedAt.getTime()) / 1000));
|
||||
const [stopped] = await db
|
||||
.update(timeEntry)
|
||||
.set({ endedAt: now, durationSeconds, updatedAt: now })
|
||||
.where(eq(timeEntry.id, active.id))
|
||||
.where(eq(timeEntry.id, entry.id))
|
||||
.returning();
|
||||
return stopped ?? null;
|
||||
}
|
||||
|
||||
async function findCurrentDayEntryId(
|
||||
db: import('@cockpit/db').Db,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
// Stoppt ALLE laufenden Timer eines Users (mehrere parallele möglich).
|
||||
async function stopAllActive(db: Db, userId: string, now: Date): Promise<number> {
|
||||
const actives = await db
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(and(eq(timeEntry.userId, userId), isNull(timeEntry.endedAt)));
|
||||
for (const a of actives) await stopOneEntry(db, a, now);
|
||||
return actives.length;
|
||||
}
|
||||
|
||||
async function findCurrentDayEntryId(db: Db, userId: string): Promise<string | null> {
|
||||
const date = todayIso();
|
||||
const row = (
|
||||
await db
|
||||
@@ -82,68 +77,97 @@ async function findCurrentDayEntryId(
|
||||
|
||||
export const timeRouter = router({
|
||||
/**
|
||||
* Aktiver Eintrag (oder null) für den eingeloggten User.
|
||||
* Alle laufenden Timer des eingeloggten Users (mehrere parallel möglich).
|
||||
*/
|
||||
now: protectedProcedure.query(async ({ ctx }) => {
|
||||
const row = (
|
||||
await ctx.db
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(and(eq(timeEntry.userId, ctx.user.id), isNull(timeEntry.endedAt)))
|
||||
.limit(1)
|
||||
)[0];
|
||||
if (!row) return null;
|
||||
// ToDo-Titel auch dann auflösen, wenn der Timer über Mitternacht läuft und
|
||||
// das ToDo nicht mehr in der heutigen Liste steht.
|
||||
let todoTitle: string | null = null;
|
||||
if (row.todoId) {
|
||||
const t = (
|
||||
await ctx.db.select({ title: todo.title }).from(todo).where(eq(todo.id, row.todoId)).limit(1)
|
||||
)[0];
|
||||
todoTitle = t?.title ?? null;
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(timeEntry)
|
||||
.where(and(eq(timeEntry.userId, ctx.user.id), isNull(timeEntry.endedAt)))
|
||||
.orderBy(desc(timeEntry.startedAt));
|
||||
const todoIds = Array.from(new Set(rows.map((r) => r.todoId).filter((x): x is string => !!x)));
|
||||
const titles = new Map<string, string>();
|
||||
if (todoIds.length > 0) {
|
||||
const tr = await ctx.db
|
||||
.select({ id: todo.id, title: todo.title })
|
||||
.from(todo)
|
||||
.where(inArray(todo.id, todoIds));
|
||||
for (const t of tr) titles.set(t.id, t.title);
|
||||
}
|
||||
return { ...row, todoTitle };
|
||||
return rows.map((r) => ({ ...r, todoTitle: r.todoId ? (titles.get(r.todoId) ?? null) : null }));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Startet einen neuen Eintrag; stoppt automatisch einen evtl. laufenden.
|
||||
* Startet einen neuen Timer — parallel zu evtl. bereits laufenden (ENTWICKLUN-6).
|
||||
*/
|
||||
start: protectedProcedure
|
||||
.input(TimeEntrySchemas.startTimeEntryInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const dayEntryId = await findCurrentDayEntryId(ctx.db, ctx.user.id);
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
await stopCurrent(tx, ctx.user.id, now);
|
||||
const [created] = await tx
|
||||
const [created] = await ctx.db
|
||||
.insert(timeEntry)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
dayEntryId,
|
||||
todoId: input.todoId ?? null,
|
||||
category: input.category,
|
||||
description: input.description ?? null,
|
||||
startedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('Time-Entry konnte nicht angelegt werden.');
|
||||
return created;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stoppt einen bestimmten laufenden Timer.
|
||||
*/
|
||||
stop: protectedProcedure.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
const existing = (
|
||||
await ctx.db.select().from(timeEntry).where(eq(timeEntry.id, input.id)).limit(1)
|
||||
)[0];
|
||||
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
|
||||
if (existing.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Nicht dein Eintrag.' });
|
||||
}
|
||||
if (existing.endedAt) return existing;
|
||||
return stopOneEntry(ctx.db, existing, new Date());
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stoppt alle laufenden Timer des Users.
|
||||
*/
|
||||
stopAll: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const stopped = await stopAllActive(ctx.db, ctx.user.id, new Date());
|
||||
return { stopped };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Pause: stoppt ALLE laufenden Tasks und startet eine Pause (break).
|
||||
*/
|
||||
pause: protectedProcedure
|
||||
.input(TimeEntrySchemas.pauseInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const dayEntryId = await findCurrentDayEntryId(ctx.db, ctx.user.id);
|
||||
return ctx.db.transaction(async (tx) => {
|
||||
await stopAllActive(tx, ctx.user.id, now);
|
||||
const [brk] = await tx
|
||||
.insert(timeEntry)
|
||||
.values({
|
||||
userId: ctx.user.id,
|
||||
dayEntryId,
|
||||
todoId: input.todoId ?? null,
|
||||
category: input.category,
|
||||
category: 'break',
|
||||
description: input.description ?? null,
|
||||
startedAt: now,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('Time-Entry konnte nicht angelegt werden.');
|
||||
return created;
|
||||
if (!brk) throw new Error('Pause konnte nicht gestartet werden.');
|
||||
return brk;
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Stoppt den aktiven Eintrag.
|
||||
*/
|
||||
stop: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const now = new Date();
|
||||
const stopped = await stopCurrent(ctx.db, ctx.user.id, now);
|
||||
if (!stopped) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aktuell läuft kein Timer.' });
|
||||
}
|
||||
return stopped;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Alle Time-Entries des heutigen Tages plus aktiver.
|
||||
*/
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX "uniq_time_entry_user_active";--> statement-breakpoint
|
||||
CREATE INDEX "idx_time_entry_user_active" ON "time_entry" USING btree ("user_id") WHERE "time_entry"."ended_at" IS NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -92,6 +92,13 @@
|
||||
"when": 1780761840614,
|
||||
"tag": "0012_todo_unplanned",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1780763141548,
|
||||
"tag": "0013_parallel_timers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -415,7 +415,8 @@ export const timeEntry = pgTable(
|
||||
},
|
||||
(t) => ({
|
||||
idxUserStart: index('idx_time_entry_user_start').on(t.userId, t.startedAt),
|
||||
idxUserActive: uniqueIndex('uniq_time_entry_user_active')
|
||||
// Kein Unique mehr: mehrere parallele aktive Timer pro User erlaubt (ENTWICKLUN-6).
|
||||
idxUserActive: index('idx_time_entry_user_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.endedAt} IS NULL`),
|
||||
idxTodo: index('idx_time_entry_todo').on(t.todoId),
|
||||
|
||||
@@ -36,6 +36,11 @@ export const updateTimeEntryInput = z.object({
|
||||
});
|
||||
export type UpdateTimeEntryInput = z.infer<typeof updateTimeEntryInput>;
|
||||
|
||||
export const pauseInput = z.object({
|
||||
description: z.string().max(200).optional(),
|
||||
});
|
||||
export type PauseInput = z.infer<typeof pauseInput>;
|
||||
|
||||
export const teamTimelineInput = z.object({
|
||||
date: z
|
||||
.string()
|
||||
|
||||
Reference in new issue
Block a user