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.
*/