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,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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <span className="font-mono">{hhmm(checkedInAt)}</span>
|
||||
</span>
|
||||
{checkedOutAt ? (
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
|
||||
{duration && ` · ${duration}`}
|
||||
</span>
|
||||
<>
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
|
||||
{duration && ` · ${duration}`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
disabled={reopen.isPending}
|
||||
onClick={() => reopen.mutate()}
|
||||
>
|
||||
<Play size={14} /> Arbeit fortsetzen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user