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:
1 parent
b39c67d1aa
commit
39e259f3ac
5 files changed
+308
-49
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user