diff --git a/apps/api/src/routers/time.ts b/apps/api/src/routers/time.ts index ea4b359..963076f 100644 --- a/apps/api/src/routers/time.ts +++ b/apps/api/src/routers/time.ts @@ -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): number { return Math.round(total / 1000); } -async function stopCurrent( - tx: import('@cockpit/db').Db, - userId: string, - now: Date, -): Promise { - 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 { + 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 { +// Stoppt ALLE laufenden Timer eines Users (mehrere parallele möglich). +async function stopAllActive(db: Db, userId: string, now: Date): Promise { + 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 { 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(); + 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. */ diff --git a/apps/web/src/components/time/TimerPill.tsx b/apps/web/src/components/time/TimerPill.tsx index 3d790e2..47e5748 100644 --- a/apps/web/src/components/time/TimerPill.tsx +++ b/apps/web/src/components/time/TimerPill.tsx @@ -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 = Object.fromEntries( TIME_CATEGORIES.map((c) => [c.value, c.label]), ); +const CATEGORY_DOT: Record = { + 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(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 (
- {active && isPause ? ( - // --- Pause läuft --- + {count > 0 ? ( - ) : active ? ( - // --- Task läuft --- - <> - - - ) : ( - // --- Idle --- - <> - - - + )} - {pickerOpen && (!active || isPause) && ( + + + {pickerOpen && ( t.status === 'open')} onClose={() => setPickerOpen(false)} @@ -154,107 +150,80 @@ export function TimerPill() { /> )} - {pausePanelOpen && active && isPause && ( - { - setPausePanelOpen(false); + {panelOpen && count > 0 && ( + { + setPanelOpen(false); setPickerOpen(true); }} - onEnd={() => { - setPausePanelOpen(false); - stopMutation.mutate(undefined, { onSuccess: () => toast('Pause beendet.') }); - }} - onSavedGrund={() => setPausePanelOpen(false)} + onChanged={invalidate} /> )}
); } -function PauseButton({ onClick, pending }: { onClick: () => void; pending: boolean }) { - return ( - - ); -} - -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; }) { - 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 ( -
-

Pause läuft

-
- -
- - -
+ Weiteren Task starten + ); } @@ -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(''); diff --git a/apps/web/src/routes/heute.tsx b/apps/web/src/routes/heute.tsx index 316025f..92bb873 100644 --- a/apps/web/src/routes/heute.tsx +++ b/apps/web/src/routes/heute.tsx @@ -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({