From 8fd72bcb693054d6869ffea17bebefe2737950f9 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 6 Jun 2026 17:29:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(zeit):=20=C3=BCber=20Mitternacht=20laufende?= =?UTF-8?q?=20Timer=20bleiben=20sichtbar=20(ENTWICKLUN-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein um 23:00 gestarteter, noch laufender Timer hatte Start-Datum = gestern und fiel nach Mitternacht aus listToday/teamToday (Filter nach Start-Datum=heute) — nur die TimerPill zeigte ihn noch. - listToday/teamToday: Filter „overlapsToday" = heute gestartet ODER heute beendet (Mitternacht überschritten) ODER laufend → Eintrag bleibt sichtbar. - Tages-Summen clampen den Anteil auf den heutigen Tag (berlinDayStartMs, DST-korrekt) → über Mitternacht gelaufene Blöcke werden nicht falsch zugezählt. - ToDo-Titel werden tagesübergreifend serverseitig aufgelöst (time.now + listToday) → Task bleibt benannt, auch wenn das ToDo von gestern ist. - Timeline zeigt das Start-Datum, wenn ein Eintrag nicht heute begonnen hat. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/lib/werktag.test.ts | 16 +++- apps/api/src/lib/werktag.ts | 23 +++++ apps/api/src/routers/time.ts | 93 ++++++++++++------- .../src/components/time/TimelineSection.tsx | 8 +- apps/web/src/components/time/TimerPill.tsx | 1 + 5 files changed, 107 insertions(+), 34 deletions(-) diff --git a/apps/api/src/lib/werktag.test.ts b/apps/api/src/lib/werktag.test.ts index 0d48270..da18631 100644 --- a/apps/api/src/lib/werktag.test.ts +++ b/apps/api/src/lib/werktag.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { formatIsoDate, isWerktag, nextWerktag, previousWerktag, werktageBetween } from './werktag'; +import { + formatIsoDate, + isWerktag, + nextWerktag, + previousWerktag, + werktageBetween, + berlinDayStartMs, +} from './werktag'; describe('werktag', () => { it('formatIsoDate liefert YYYY-MM-DD in Berlin TZ', () => { @@ -27,4 +34,11 @@ describe('werktag', () => { // Mo 2026-06-08 bis Mo 2026-06-15 (exkl.): 5 Werktage expect(werktageBetween('2026-06-08', '2026-06-15')).toBe(5); }); + + it('berlinDayStartMs ist DST-korrekt', () => { + // Sommer (CEST, +2): 00:00 Berlin = 22:00 UTC am Vortag + expect(berlinDayStartMs('2026-06-06')).toBe(Date.parse('2026-06-05T22:00:00Z')); + // Winter (CET, +1): 00:00 Berlin = 23:00 UTC am Vortag + expect(berlinDayStartMs('2026-01-15')).toBe(Date.parse('2026-01-14T23:00:00Z')); + }); }); diff --git a/apps/api/src/lib/werktag.ts b/apps/api/src/lib/werktag.ts index f93bdad..3f7dcf2 100644 --- a/apps/api/src/lib/werktag.ts +++ b/apps/api/src/lib/werktag.ts @@ -22,6 +22,29 @@ export function formatIsoDate(d: Date): string { return fmt.format(d); // en-CA liefert ISO YYYY-MM-DD } +/** + * Epoch-ms von 00:00 Europe/Berlin am gegebenen Tag (DST-korrekt). Genutzt, um + * Tagesgrenzen für Zeit-Auswertungen zu bilden (z. B. Tagesanteil eines über + * Mitternacht laufenden Timers). + */ +export function berlinDayStartMs(iso: string): number { + const utcMidnight = Date.parse(`${iso}T00:00:00Z`); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: TIMEZONE, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date(utcMidnight)); + const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0'); + const asIfUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')); + const offset = asIfUtc - utcMidnight; // ms, die Berlin der UTC voraus ist + return utcMidnight - offset; +} + export function parseIsoDate(s: string): Date { if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) { throw new Error(`Ungültiges ISO-Datum: ${s}`); diff --git a/apps/api/src/routers/time.ts b/apps/api/src/routers/time.ts index f32bdeb..dbc147a 100644 --- a/apps/api/src/routers/time.ts +++ b/apps/api/src/routers/time.ts @@ -1,10 +1,20 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { and, eq, isNull, desc, sql } from 'drizzle-orm'; -import { timeEntry, dayEntry, type TimeEntry } from '@cockpit/db'; +import { and, eq, isNull, desc, sql, inArray } from 'drizzle-orm'; +import { timeEntry, dayEntry, todo, type TimeEntry } from '@cockpit/db'; import { TimeEntrySchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; -import { todayIso } from '../lib/werktag.js'; +import { todayIso, berlinDayStartMs } from '../lib/werktag.js'; + +// Einträge, die „heute" betreffen: heute gestartet ODER heute beendet (über +// Mitternacht gelaufen) ODER aktuell laufend. Gemeinsam für listToday/teamToday. +function overlapsToday(today: string) { + return sql`( + (${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today} + OR (${timeEntry.endedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today} + OR ${timeEntry.endedAt} IS NULL + )`; +} const idInput = z.object({ id: z.string().uuid() }); @@ -82,7 +92,17 @@ export const timeRouter = router({ .where(and(eq(timeEntry.userId, ctx.user.id), isNull(timeEntry.endedAt))) .limit(1) )[0]; - return row ?? null; + 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; + } + return { ...row, todoTitle }; }), /** @@ -129,30 +149,40 @@ export const timeRouter = router({ */ listToday: protectedProcedure.query(async ({ ctx }) => { const today = todayIso(); + const dayStart = berlinDayStartMs(today); + const now = Date.now(); const rows = await ctx.db .select() .from(timeEntry) - .where( - and( - eq(timeEntry.userId, ctx.user.id), - // Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset. - sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`, - ), - ) + .where(and(eq(timeEntry.userId, ctx.user.id), overlapsToday(today))) .orderBy(desc(timeEntry.startedAt)); - const now = Date.now(); - // „Gesamt" = Wall-Clock (überlappende Tasks zählen einmal). - const totalSeconds = unionSeconds( - rows.map((r) => [r.startedAt.getTime(), r.endedAt ? r.endedAt.getTime() : now] as const), + + // ToDo-Titel tagesübergreifend auflösen (ToDo kann von gestern sein). + 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); + } + + // Für die Tages-Summen nur den HEUTIGEN Anteil zählen (über Mitternacht + // gelaufene Blöcke nicht doppelt/falsch zuschlagen). + const clampStart = (r: TimeEntry) => Math.max(r.startedAt.getTime(), dayStart); + const endMs = (r: TimeEntry) => (r.endedAt ? r.endedAt.getTime() : now); + const totalSeconds = unionSeconds(rows.map((r) => [clampStart(r), endMs(r)] as const)); + const trackedSeconds = rows.reduce( + (acc, r) => acc + Math.max(0, Math.round((endMs(r) - clampStart(r)) / 1000)), + 0, ); - // „getrackt" = Summe aller Task-Zeiten (kann bei parallelem Tracken größer sein). - const trackedSeconds = rows.reduce((acc, r) => { - if (r.endedAt) { - return acc + (r.durationSeconds ?? Math.round((r.endedAt.getTime() - r.startedAt.getTime()) / 1000)); - } - return acc + Math.round((now - r.startedAt.getTime()) / 1000); - }, 0); - return { entries: rows, totalSeconds, trackedSeconds }; + + const entries = rows.map((r) => ({ + ...r, + todoTitle: r.todoId ? (titles.get(r.todoId) ?? null) : null, + })); + return { entries, totalSeconds, trackedSeconds }; }), /** @@ -241,14 +271,13 @@ export const timeRouter = router({ */ teamToday: protectedProcedure.query(async ({ ctx }) => { const today = todayIso(); - const { todo } = await import('@cockpit/db'); - const { inArray } = await import('drizzle-orm'); + const dayStart = berlinDayStartMs(today); const rows = await ctx.db .select() .from(timeEntry) - // Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset. - .where(sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`); + // Heute betroffen: heute gestartet/beendet oder laufend (auch über Mitternacht). + .where(overlapsToday(today)); const todoIds = Array.from( new Set(rows.map((r) => r.todoId).filter((x): x is string => Boolean(x))), @@ -288,13 +317,13 @@ export const timeRouter = router({ active: null, intervals: [], }; - const duration = r.endedAt - ? (r.durationSeconds ?? - Math.round((r.endedAt.getTime() - r.startedAt.getTime()) / 1000)) - : Math.round((now - r.startedAt.getTime()) / 1000); + // Nur den heutigen Anteil zählen (über Mitternacht gelaufene Blöcke clampen). + const startMs = Math.max(r.startedAt.getTime(), dayStart); + const endMs = r.endedAt ? r.endedAt.getTime() : now; + const duration = Math.max(0, Math.round((endMs - startMs) / 1000)); bucket.trackedSeconds += duration; bucket.byCategory[r.category] = (bucket.byCategory[r.category] ?? 0) + duration; - bucket.intervals.push([r.startedAt.getTime(), r.endedAt ? r.endedAt.getTime() : now]); + bucket.intervals.push([startMs, endMs]); if (!r.endedAt) { bucket.active = { startedAt: r.startedAt.toISOString(), diff --git a/apps/web/src/components/time/TimelineSection.tsx b/apps/web/src/components/time/TimelineSection.tsx index e16c71e..d016952 100644 --- a/apps/web/src/components/time/TimelineSection.tsx +++ b/apps/web/src/components/time/TimelineSection.tsx @@ -64,7 +64,7 @@ export function TimelineSection() { setEditing(e.id)} @@ -92,6 +92,7 @@ type TimeEntryRow = { endedAt: string | Date | null; durationSeconds: number | null; todoId: string | null; + todoTitle?: string | null; category: string; description: string | null; }; @@ -237,6 +238,11 @@ function TimelineRow({ return (
+ {start.toDateString() !== new Date().toDateString() && ( + + {pad(start.getDate())}.{pad(start.getMonth() + 1)}. + + )} {pad(start.getHours())}:{pad(start.getMinutes())} {end && ( <> diff --git a/apps/web/src/components/time/TimerPill.tsx b/apps/web/src/components/time/TimerPill.tsx index c01bde3..3d790e2 100644 --- a/apps/web/src/components/time/TimerPill.tsx +++ b/apps/web/src/components/time/TimerPill.tsx @@ -80,6 +80,7 @@ export function TimerPill() { 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')