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

+84 -60
View File
@@ -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.
*/
+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'}
@@ -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
}
]
}
+2 -1
View File
@@ -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()