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

+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,