fix(zeit): über Mitternacht laufende Timer bleiben sichtbar (ENTWICKLUN-14)
ci / quality (push) Failing after 40s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

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) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 17:29:47 +02:00
1 parent 040dd62735
commit 8fd72bcb69
5 files changed
+107 -34

No files matched your search

+15 -1
View File
@@ -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'));
});
});
+23
View File
@@ -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}`);
+61 -32
View File
@@ -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<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);
}
// 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(),
@@ -64,7 +64,7 @@ export function TimelineSection() {
<TimelineRow
key={e.id}
entry={e}
todoTitle={e.todoId ? todoTitles.get(e.todoId) : undefined}
todoTitle={e.todoTitle ?? (e.todoId ? todoTitles.get(e.todoId) : undefined)}
todos={todayQuery.data?.todos ?? []}
isEditing={editing === e.id}
onEdit={() => 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 (
<div className="group flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
<div className="w-28 shrink-0 font-mono text-xs text-[var(--color-ink-muted)]">
{start.toDateString() !== new Date().toDateString() && (
<span className="mr-1 text-[var(--color-ink-faint)]">
{pad(start.getDate())}.{pad(start.getMonth() + 1)}.
</span>
)}
{pad(start.getHours())}:{pad(start.getMinutes())}
{end && (
<>
@@ -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')