From 39e259f3ac894a20a873e4e11de1e160066f3425 Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 7 Jun 2026 10:48:23 +0200 Subject: [PATCH] Arbeitstag-Grenze auf 6 Uhr + Feierabend reversibel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nachtarbeit über Mitternacht brach bisher den Check-Out ("Du bist heute noch nicht locked in") und sperrte nach versehentlichem Feierabend den Tag. - Tag = Kalendertag des Check-ins; eine über Mitternacht laufende Session zaehlt zum Starttag, bis 06:00 des Folgetags (isWorkdaySealed) - checkOut/today finden den offenen Eintrag statt stur "heute" - reopen ("Arbeit fortsetzen") hebt einen Feierabend wieder auf - applyWorkdayBoundary: nahtloser 6-Uhr-Schnitt (Auto-Checkout + Folge-Tag, offene ToDos wandern mit) - Reflexion/ToDo-Edit folgen der 6-Uhr-Grenze statt Mitternacht - Cron apply-workday-boundaries fuer Team-Dashboard-Konsistenz Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/cron/routes.ts | 31 +++- apps/api/src/lib/werktag.test.ts | 36 +++++ apps/api/src/lib/werktag.ts | 34 +++++ apps/api/src/routers/day-entry.ts | 229 ++++++++++++++++++++++++------ apps/web/src/routes/heute.tsx | 27 +++- 5 files changed, 308 insertions(+), 49 deletions(-) diff --git a/apps/api/src/cron/routes.ts b/apps/api/src/cron/routes.ts index 70fc73b..fc759b1 100644 --- a/apps/api/src/cron/routes.ts +++ b/apps/api/src/cron/routes.ts @@ -1,8 +1,10 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { isNull } from 'drizzle-orm'; +import { dayEntry } from '@cockpit/db'; import { loadEnv } from '../env.js'; import { logger } from '../lib/logger.js'; import { getDb } from '../db.js'; -import { publishYesterdaysEntries } from '../routers/day-entry.js'; +import { publishYesterdaysEntries, applyWorkdayBoundary } from '../routers/day-entry.js'; import { previousWerktag, todayIso } from '../lib/werktag.js'; function authorize(req: FastifyRequest): void { @@ -15,6 +17,33 @@ function authorize(req: FastifyRequest): void { } export async function registerCronRoutes(app: FastifyInstance): Promise { + // 6-Uhr-Schnitt teamweit: beendet offene Nacht-Einträge, deren Arbeitstag + // abgelaufen ist, und startet den Folge-Tag. Lazy passiert das ohnehin beim + // nächsten Request der Person — dieser Cron (≈06:05) hält das Team-Dashboard + // konsistent, auch ohne dass die Person aktiv wird. + app.post('/internal/cron/apply-workday-boundaries', async (req, reply) => { + try { + authorize(req); + } catch (err) { + reply.code(401); + logger.warn({ err }, 'unauthorized cron call'); + return { error: 'unauthorized' }; + } + const { db } = getDb(); + const now = new Date(); + const openRows = await db + .selectDistinct({ userId: dayEntry.userId }) + .from(dayEntry) + .where(isNull(dayEntry.checkedOutAt)); + let cut = 0; + for (const { userId } of openRows) { + await applyWorkdayBoundary(db, userId, now); + cut++; + } + logger.info({ usersChecked: cut }, 'applied workday boundaries'); + return { ok: true, usersChecked: cut }; + }); + app.post('/internal/cron/publish-day-entries', async (req, reply) => { try { authorize(req); diff --git a/apps/api/src/lib/werktag.test.ts b/apps/api/src/lib/werktag.test.ts index da18631..e469e24 100644 --- a/apps/api/src/lib/werktag.test.ts +++ b/apps/api/src/lib/werktag.test.ts @@ -6,6 +6,9 @@ import { previousWerktag, werktageBetween, berlinDayStartMs, + addCalendarDays, + workdayCutoffMs, + isWorkdaySealed, } from './werktag'; describe('werktag', () => { @@ -41,4 +44,37 @@ describe('werktag', () => { // Winter (CET, +1): 00:00 Berlin = 23:00 UTC am Vortag expect(berlinDayStartMs('2026-01-15')).toBe(Date.parse('2026-01-14T23:00:00Z')); }); + + it('addCalendarDays verschiebt Kalendertage (auch über Monatsgrenze)', () => { + expect(addCalendarDays('2026-06-07', 1)).toBe('2026-06-08'); + expect(addCalendarDays('2026-06-30', 1)).toBe('2026-07-01'); + expect(addCalendarDays('2026-06-07', -1)).toBe('2026-06-06'); + }); + + it('workdayCutoffMs ist 06:00 Berlin am Folgetag (DST-korrekt)', () => { + // Sommer (CEST, +2): 06:00 Berlin = 04:00 UTC + expect(workdayCutoffMs('2026-06-06')).toBe(Date.parse('2026-06-07T04:00:00Z')); + // Winter (CET, +1): 06:00 Berlin = 05:00 UTC + expect(workdayCutoffMs('2026-01-15')).toBe(Date.parse('2026-01-16T05:00:00Z')); + }); + + describe('isWorkdaySealed — der Arbeitstag = Check-in-Kalendertag', () => { + // Eintrag mit Datum Sa 2026-06-06 (Check-in irgendwann an dem Tag). + const entry = '2026-06-06'; + it('vor Mitternacht: nicht versiegelt', () => { + expect(isWorkdaySealed(entry, new Date('2026-06-06T22:00:00+02:00'))).toBe(false); + }); + it('nach Mitternacht, aber vor 06:00: noch der Arbeitstag (nicht versiegelt)', () => { + // 03:00 in der Nacht → gehört noch zum 06. + expect(isWorkdaySealed(entry, new Date('2026-06-07T03:00:00+02:00'))).toBe(false); + }); + it('um/nach 06:00 des Folgetags: versiegelt', () => { + expect(isWorkdaySealed(entry, new Date('2026-06-07T06:00:00+02:00'))).toBe(true); + expect(isWorkdaySealed(entry, new Date('2026-06-07T09:00:00+02:00'))).toBe(true); + }); + it('ein nach Mitternacht begonnener Tag (Datum = 07.) gilt um 06:00 noch nicht als versiegelt', () => { + const sameDayStart = '2026-06-07'; + expect(isWorkdaySealed(sameDayStart, new Date('2026-06-07T06:00:00+02:00'))).toBe(false); + }); + }); }); diff --git a/apps/api/src/lib/werktag.ts b/apps/api/src/lib/werktag.ts index 3f7dcf2..8cd3c39 100644 --- a/apps/api/src/lib/werktag.ts +++ b/apps/api/src/lib/werktag.ts @@ -5,6 +5,14 @@ import { isGermanHoliday } from './holidays.js'; const TIMEZONE = 'Europe/Berlin'; +/** + * Uhrzeit (Berlin), zu der ein Arbeitstag „kippt": Wer um 06:00 noch eingestempelt + * ist, dessen Tag wird automatisch beendet (siehe `applyWorkdayBoundary`). Bis dahin + * gehört eine über Mitternacht laufende Session noch zum Tag, an dem eingestempelt + * wurde — der Arbeitstag eines Eintrags = der Kalendertag des Check-ins. + */ +export const WORKDAY_RESET_HOUR = 6; + /** * Gibt das aktuelle Datum (oder `now`) als `YYYY-MM-DD` in Europe/Berlin zurück. */ @@ -12,6 +20,32 @@ export function todayIso(now: Date = new Date()): string { return formatIsoDate(now); } +/** + * Verschiebt ein ISO-Datum um `n` Kalendertage (nicht Werktage). DST-robust, weil + * `parseIsoDate` von 12:00 UTC ausgeht und in Berlin formatiert wird. + */ +export function addCalendarDays(iso: string, n: number): string { + const d = parseIsoDate(iso); + return formatIsoDate(new Date(d.getTime() + n * 24 * 60 * 60 * 1000)); +} + +/** + * Epoch-ms des Zeitpunkts, ab dem der Arbeitstag mit Eintrags-Datum `entryDate` + * abgelaufen ist: 06:00 Europe/Berlin am Folgetag (DST-korrekt). + */ +export function workdayCutoffMs(entryDate: string): number { + return berlinDayStartMs(addCalendarDays(entryDate, 1)) + WORKDAY_RESET_HOUR * 60 * 60 * 1000; +} + +/** + * True, wenn der Arbeitstag eines Eintrags (Datum = Check-in-Kalendertag) bereits + * „versiegelt" ist, d. h. `now` liegt am/nach 06:00 des Folgetags. Versiegelte Tage + * sind read-only und werden nicht mehr als aktiver Tag angezeigt. + */ +export function isWorkdaySealed(entryDate: string, now: Date = new Date()): boolean { + return now.getTime() >= workdayCutoffMs(entryDate); +} + export function formatIsoDate(d: Date): string { const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: TIMEZONE, diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index c786538..e3596a2 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -1,11 +1,127 @@ import { TRPCError } from '@trpc/server'; import { and, eq, isNull, lt, asc, desc, inArray } from 'drizzle-orm'; -import { dayEntry, todo, blocker, user, type Todo } from '@cockpit/db'; +import { dayEntry, todo, blocker, user, type Db, type DayEntry, type Todo } from '@cockpit/db'; import { DayEntrySchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; -import { todayIso, previousWerktag, nextWerktag } from '../lib/werktag.js'; +import { + todayIso, + previousWerktag, + nextWerktag, + addCalendarDays, + workdayCutoffMs, + isWorkdaySealed, +} from '../lib/werktag.js'; import { kimaiClockIn, kimaiClockOut, kimaiEnabled } from '../lib/kimai.js'; +/** + * Der 6-Uhr-Schnitt: Ist um 06:00 (Berlin) noch ein Eintrag offen, dessen + * Arbeitstag (= Check-in-Kalendertag) abgelaufen ist, wird er automatisch beendet + * (checkedOutAt = 06:00 des Folgetags) und ein nahtloser Folge-Tag gestartet — + * offene ToDos wandern mit. So entstehen aus Nacht-Durcharbeiten saubere + * Tagesgrenzen, ohne dass der Nutzer manuell aus- und wieder einstempeln muss. + * + * Idempotent: ohne offenen, abgelaufenen Eintrag passiert nichts. Die Schleife + * deckt (selten) mehrtägiges Durchlaufen ab; das Limit ist ein Sicherheitsnetz. + */ +export async function applyWorkdayBoundary(db: Db, userId: string, now: Date): Promise { + for (let i = 0; i < 14; i++) { + const open = ( + await db + .select() + .from(dayEntry) + .where(and(eq(dayEntry.userId, userId), isNull(dayEntry.checkedOutAt))) + .orderBy(desc(dayEntry.date)) + .limit(1) + )[0]; + if (!open || !isWorkdaySealed(open.date, now)) return; + + const cutoff = new Date(workdayCutoffMs(open.date)); + const nextDate = addCalendarDays(open.date, 1); + + await db.transaction(async (tx) => { + // 1) Alten Tag um 06:00 beenden. + await tx + .update(dayEntry) + .set({ checkedOutAt: cutoff, updatedAt: now }) + .where(eq(dayEntry.id, open.id)); + + if (open.kimaiTimesheetId !== null && kimaiEnabled()) { + await kimaiClockOut({ timesheetId: open.kimaiTimesheetId }); + } + + // 2) Folge-Tag nur anlegen, wenn es ihn noch nicht gibt (unique userId+date). + const existsNext = ( + await tx + .select({ id: dayEntry.id }) + .from(dayEntry) + .where(and(eq(dayEntry.userId, userId), eq(dayEntry.date, nextDate))) + .limit(1) + )[0]; + if (existsNext) return; + + const [created] = await tx + .insert(dayEntry) + .values({ userId, date: nextDate, status: 'begun', checkedInAt: cutoff }) + .returning(); + if (!created) throw new Error('Folge-Tag konnte nicht angelegt werden.'); + + // 3) Offene ToDos in den neuen Tag übernehmen (im alten als übertragen markiert). + const openTodos = await tx + .select() + .from(todo) + .where(and(eq(todo.dayEntryId, open.id), eq(todo.status, 'open'))) + .orderBy(asc(todo.order)); + if (openTodos.length > 0) { + await tx.insert(todo).values( + openTodos.map((t, idx) => ({ + dayEntryId: created.id, + userId, + date: nextDate, + title: t.title, + order: idx, + bezugKind: t.bezugKind, + bezugId: t.bezugId, + carriedOver: true, + })), + ); + await tx + .update(todo) + .set({ carriedOver: true }) + .where( + inArray( + todo.id, + openTodos.map((t) => t.id), + ), + ); + } + }); + } +} + +/** + * Der aktive Arbeitstag des Nutzers nach Anwendung des 6-Uhr-Schnitts: der jüngste + * Eintrag, der noch nicht versiegelt ist (offen ODER bereits ausgecheckt, aber vor + * 06:00 des Folgetags — also noch fortsetzbar). `null`, wenn kein laufender/heutiger + * Tag existiert (→ Frontend zeigt das Einstempel-Formular). + */ +export async function getActiveEntry( + db: Db, + userId: string, + now: Date, +): Promise { + await applyWorkdayBoundary(db, userId, now); + const entry = ( + await db + .select() + .from(dayEntry) + .where(eq(dayEntry.userId, userId)) + .orderBy(desc(dayEntry.date)) + .limit(1) + )[0]; + if (!entry || isWorkdaySealed(entry.date, now)) return null; + return entry; +} + export const dayEntryRouter = router({ /** * Heutige ToDos aller aktiven Mitarbeiter, gruppiert pro User. @@ -65,25 +181,22 @@ export const dayEntryRouter = router({ }), /** - * Heute-Status der eigenen Person. + * Heute-Status der eigenen Person. „Heute" = der aktive Arbeitstag: ein laufender + * (offener) Eintrag bleibt aktiv, auch über Mitternacht hinaus, bis 06:00 des + * Folgetags. Ein bereits ausgecheckter Tag bleibt bis dahin sichtbar (fortsetzbar). */ today: protectedProcedure.query(async ({ ctx }) => { - const date = todayIso(); - const rows = await ctx.db - .select() - .from(dayEntry) - .where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date))) - .limit(1); - const entry = rows[0] ?? null; + const now = new Date(); + const entry = await getActiveEntry(ctx.db, ctx.user.id, now); if (!entry) { - return { date, entry: null, todos: [] as Todo[] }; + return { date: todayIso(now), entry: null, todos: [] as Todo[] }; } const todos = await ctx.db .select() .from(todo) .where(eq(todo.dayEntryId, entry.id)) .orderBy(todo.order); - return { date, entry, todos }; + return { date: entry.date, entry, todos }; }), /** @@ -92,6 +205,8 @@ export const dayEntryRouter = router({ checkIn: protectedProcedure .input(DayEntrySchemas.checkInInput) .mutation(async ({ ctx, input }) => { + // Erst einen ggf. abgelaufenen Nacht-Eintrag sauber abschließen (6-Uhr-Schnitt). + await applyWorkdayBoundary(ctx.db, ctx.user.id, new Date()); const date = input.date ?? todayIso(); // Verhindert Doppel-Einstempeln. Wenn bereits Einträge existieren, Fehler. @@ -181,24 +296,25 @@ export const dayEntryRouter = router({ }), /** - * Check-Out / Arbeitsende: markiert das Ende der Arbeits-Session. Best-effort + * Check-Out / Arbeitsende: beendet den laufenden (offenen) Arbeitstag — egal, ob + * er heute oder gestern (Nachtarbeit über Mitternacht) begonnen wurde. Best-effort * Kimai-Clock-Out, falls beim Check-In ein Timesheet gestartet wurde. */ checkOut: protectedProcedure.mutation(async ({ ctx }) => { - const date = todayIso(); + const now = new Date(); + await applyWorkdayBoundary(ctx.db, ctx.user.id, now); const entry = ( await ctx.db .select() .from(dayEntry) - .where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date))) + .where(and(eq(dayEntry.userId, ctx.user.id), isNull(dayEntry.checkedOutAt))) + .orderBy(desc(dayEntry.date)) .limit(1) )[0]; if (!entry) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute noch nicht locked in.' }); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist gerade nicht eingestempelt.' }); } - if (entry.checkedOutAt) return entry; - const now = new Date(); const [updated] = await ctx.db .update(dayEntry) .set({ checkedOutAt: now, updatedAt: now }) @@ -211,6 +327,45 @@ export const dayEntryRouter = router({ return updated; }), + /** + * „Arbeit fortsetzen" — hebt einen Feierabend wieder auf (versehentlich geklickt + * oder doch noch weitergearbeitet). Öffnet den aktiven, noch nicht versiegelten + * Tag erneut (checkedOutAt → null). Möglich, solange der Arbeitstag läuft, d. h. + * vor 06:00 des Folgetags. Danach ist der Tag final → neu einstempeln. + */ + reopen: protectedProcedure.mutation(async ({ ctx }) => { + const now = new Date(); + const entry = await getActiveEntry(ctx.db, ctx.user.id, now); + if (!entry) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Kein fortsetzbarer Arbeitstag — der letzte Tag ist abgeschlossen. Stempel neu ein.', + }); + } + if (!entry.checkedOutAt) return entry; // läuft bereits + + const [updated] = await ctx.db + .update(dayEntry) + .set({ checkedOutAt: null, updatedAt: now }) + .where(eq(dayEntry.id, entry.id)) + .returning(); + + // Best-effort: Kimai wieder anlaufen lassen, falls aktiv. + if (kimaiEnabled()) { + const tsId = await kimaiClockIn({ + begin: now, + description: `ebw — ${ctx.user.name} (fortgesetzt)`, + }); + if (tsId !== null) { + await ctx.db + .update(dayEntry) + .set({ kimaiTimesheetId: tsId, kimaiSynced: true }) + .where(eq(dayEntry.id, entry.id)); + } + } + return updated; + }), + /** * ToDos im Verlauf des Tages ändern (vor Reflexion). */ @@ -257,8 +412,8 @@ export const dayEntryRouter = router({ /** * Einzelnen ToDo-Titel korrigieren (z. B. Tippfehler) — auch noch in der - * Reflexion. Erlaubt, solange das ToDo der eigenen Person gehört und vom - * heutigen Berlin-Tag ist (gleiche Editierbarkeits-Regel wie die Reflexion). + * Reflexion. Erlaubt, solange der Arbeitstag des ToDos noch läuft (vor 06:00 des + * Folgetags); gleiche Editierbarkeits-Regel wie die Reflexion. */ editTodoTitle: protectedProcedure .input(DayEntrySchemas.editTodoTitleInput) @@ -272,7 +427,7 @@ export const dayEntryRouter = router({ if (!existing) { throw new TRPCError({ code: 'NOT_FOUND', message: 'ToDo nicht gefunden.' }); } - if (existing.date !== todayIso(new Date())) { + if (isWorkdaySealed(existing.date, new Date())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'ToDos eines Vortags können nicht mehr bearbeitet werden.', @@ -293,18 +448,11 @@ export const dayEntryRouter = router({ addTodo: protectedProcedure .input(DayEntrySchemas.addTodoInput) .mutation(async ({ ctx, input }) => { - const date = todayIso(); - const entry = ( - await ctx.db - .select() - .from(dayEntry) - .where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date))) - .limit(1) - )[0]; + const entry = await getActiveEntry(ctx.db, ctx.user.id, new Date()); if (!entry) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Du bist heute noch nicht locked in.', + message: 'Du bist gerade nicht eingestempelt.', }); } const existing = await ctx.db @@ -334,16 +482,9 @@ export const dayEntryRouter = router({ addUnplanned: protectedProcedure .input(DayEntrySchemas.addUnplannedInput) .mutation(async ({ ctx, input }) => { - const date = todayIso(); - const entry = ( - await ctx.db - .select() - .from(dayEntry) - .where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date))) - .limit(1) - )[0]; + const entry = await getActiveEntry(ctx.db, ctx.user.id, new Date()); if (!entry) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute noch nicht locked in.' }); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist gerade nicht eingestempelt.' }); } const existing = await ctx.db .select({ id: todo.id }) @@ -382,10 +523,10 @@ export const dayEntryRouter = router({ if (!entry) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' }); } - // Editierbar, solange das Eintrags-Datum noch der heutige Berlin-Tag ist. - // Damit kann mehrmals am Tag aktualisiert werden; nach 00:00 (Europe/Berlin) - // ist der Vortag final und kann nicht mehr verändert werden. - if (entry.date !== todayIso(new Date())) { + // Editierbar, solange der Arbeitstag noch läuft. Damit kann mehrmals am Tag + // (auch nach Mitternacht bei Nachtarbeit) aktualisiert werden; ab 06:00 des + // Folgetags ist der Tag final und kann nicht mehr verändert werden. + if (isWorkdaySealed(entry.date, new Date())) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Reflexion eines Vortags kann nicht mehr bearbeitet werden.', @@ -554,7 +695,7 @@ export const dayEntryRouter = router({ // Helper für Cron-Endpoint export async function publishYesterdaysEntries( - ctx: { db: import('@cockpit/db').Db }, + ctx: { db: Db }, yesterdayIso: string, ): Promise { const now = new Date(); diff --git a/apps/web/src/routes/heute.tsx b/apps/web/src/routes/heute.tsx index 242c30d..8635fc3 100644 --- a/apps/web/src/routes/heute.tsx +++ b/apps/web/src/routes/heute.tsx @@ -332,6 +332,14 @@ function CheckInOutBar({ await utils.dashboard.overview.invalidate(); }, }); + const reopen = trpc.dayEntry.reopen.useMutation({ + onSuccess: async () => { + toast('Willkommen zurück — Arbeit fortgesetzt.'); + await utils.dayEntry.today.invalidate(); + await utils.dashboard.overview.invalidate(); + }, + onError: (err) => toast(err.message), + }); const hhmm = (d: string | Date | null) => d ? new Intl.DateTimeFormat('de-DE', { @@ -353,10 +361,21 @@ function CheckInOutBar({ Arbeitsantritt {hhmm(checkedInAt)} {checkedOutAt ? ( - - · Feierabend {hhmm(checkedOutAt)} - {duration && ` · ${duration}`} - + <> + + · Feierabend {hhmm(checkedOutAt)} + {duration && ` · ${duration}`} + + + ) : (