Arbeitstag-Grenze auf 6 Uhr + Feierabend reversibel

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) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 10:48:23 +02:00
1 parent b39c67d1aa
commit 39e259f3ac
5 files changed
+308 -49

No files matched your search

+30 -1
View File
@@ -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<void> {
// 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);
+36
View File
@@ -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);
});
});
});
+34
View File
@@ -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,
+185 -44
View File
@@ -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<void> {
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<DayEntry | null> {
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<number> {
const now = new Date();