fix(zeit): über Mitternacht laufende Timer bleiben sichtbar (ENTWICKLUN-14)
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:
1 parent
040dd62735
commit
8fd72bcb69
5 files changed
+107
-34
No files matched your search
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user