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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user