fix(streak): Feiertage, Wochenend-Edge & Lookback-Deckel
ci / quality (push) Failing after 38s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 55s

- Feiertage: isWerktag berücksichtigt jetzt die 9 bundesweiten gesetzlichen
  Feiertage (Oster-Computus + feste Tage) → ein Feiertag bricht die Streak
  nicht mehr (zählt wie ein Wochenende neutral). Landesspezifische Feiertage
  bewusst (noch) nicht enthalten.
- Wochenend-Edge: Reflexion an Sa/So setzte die angezeigte Streak auf 0
  (Schleife brach am Nicht-Werktag ab). Jetzt wird übersprungen statt gebrochen.
- Lookback von 120 auf 400 Tage erhöht, damit lange Streaks nicht
  abgeschnitten werden.
- Streak-Zähllogik in reine, getestete countStreakDays() ausgelagert
  (8 Streak- + 5 Feiertags-Tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 15:42:25 +02:00
1 parent 1f0c9efd59
commit bcae44eeb3
7 files changed
+256 -47

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { germanNationwideHolidays, isGermanHoliday } from './holidays.js';
import { isWerktag } from './werktag.js';
describe('germanNationwideHolidays', () => {
it('feste Feiertage 2026', () => {
const h = germanNationwideHolidays(2026);
for (const d of ['2026-01-01', '2026-05-01', '2026-10-03', '2026-12-25', '2026-12-26']) {
expect(h.has(d)).toBe(true);
}
});
it('oster-abhängige Feiertage 2026 (Ostern = 05.04.2026)', () => {
const h = germanNationwideHolidays(2026);
expect(h.has('2026-04-03')).toBe(true); // Karfreitag
expect(h.has('2026-04-06')).toBe(true); // Ostermontag
expect(h.has('2026-05-14')).toBe(true); // Christi Himmelfahrt
expect(h.has('2026-05-25')).toBe(true); // Pfingstmontag
});
it('oster-abhängige Feiertage 2025 (Ostern = 20.04.2025)', () => {
const h = germanNationwideHolidays(2025);
expect(h.has('2025-04-18')).toBe(true); // Karfreitag
expect(h.has('2025-06-09')).toBe(true); // Pfingstmontag
});
it('normale Tage sind keine Feiertage', () => {
expect(isGermanHoliday('2026-06-08')).toBe(false);
});
it('isWerktag behandelt Feiertage als Nicht-Werktag', () => {
expect(isWerktag('2026-05-01')).toBe(false); // Tag der Arbeit (Freitag)
expect(isWerktag('2026-05-25')).toBe(false); // Pfingstmontag
expect(isWerktag('2026-06-08')).toBe(true); // normaler Montag
});
});
+60
View File
@@ -0,0 +1,60 @@
// Bundesweite gesetzliche Feiertage in Deutschland. Diese 9 gelten in ALLEN
// Bundesländern — sie als Nicht-Werktag zu behandeln ist daher immer korrekt.
// Landesspezifische Feiertage (z. B. Fronleichnam, Reformationstag) sind hier
// bewusst NICHT enthalten; die können bei Bedarf pro Bundesland ergänzt werden.
const cache = new Map<number, Set<string>>();
function isoFromParts(year: number, month: number, day: number): string {
return new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10);
}
function addDays(year: number, month: number, day: number, offset: number): string {
const d = new Date(Date.UTC(year, month - 1, day));
d.setUTCDate(d.getUTCDate() + offset);
return d.toISOString().slice(0, 10);
}
// Ostersonntag (gregorianisch) nach Meeus/Jones/Butcher.
function easterSunday(year: number): { month: number; day: number } {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31); // 3 = März, 4 = April
const day = ((h + l - 7 * m + 114) % 31) + 1;
return { month, day };
}
export function germanNationwideHolidays(year: number): Set<string> {
const cached = cache.get(year);
if (cached) return cached;
const e = easterSunday(year);
const set = new Set<string>([
isoFromParts(year, 1, 1), // Neujahr
addDays(year, e.month, e.day, -2), // Karfreitag
addDays(year, e.month, e.day, 1), // Ostermontag
isoFromParts(year, 5, 1), // Tag der Arbeit
addDays(year, e.month, e.day, 39), // Christi Himmelfahrt
addDays(year, e.month, e.day, 50), // Pfingstmontag
isoFromParts(year, 10, 3), // Tag der Deutschen Einheit
isoFromParts(year, 12, 25), // 1. Weihnachtstag
isoFromParts(year, 12, 26), // 2. Weihnachtstag
]);
cache.set(year, set);
return set;
}
export function isGermanHoliday(iso: string): boolean {
const year = Number(iso.slice(0, 4));
if (!Number.isFinite(year)) return false;
return germanNationwideHolidays(year).has(iso);
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { countStreakDays } from './streak.js';
const S = (...d: string[]) => new Set(d);
const NONE = new Set<string>();
describe('countStreakDays', () => {
it('zählt aufeinanderfolgende volle Werktage', () => {
// Mi/Di/Mo voll, heute Mi
expect(
countStreakDays({
today: '2026-06-10',
fullDates: S('2026-06-10', '2026-06-09', '2026-06-08'),
absenceDates: NONE,
}),
).toBe(3);
});
it('heute noch nicht reflektiert → Schonfrist (zählt ab gestern)', () => {
expect(
countStreakDays({
today: '2026-06-10',
fullDates: S('2026-06-09', '2026-06-08'),
absenceDates: NONE,
}),
).toBe(2);
});
it('bricht bei einem ausgelassenen Werktag', () => {
// 06-09 fehlt
expect(
countStreakDays({
today: '2026-06-10',
fullDates: S('2026-06-10', '2026-06-08'),
absenceDates: NONE,
}),
).toBe(1);
});
it('Wochenende ist neutral (Fr + Mo zählt durch)', () => {
// heute Mo 06-08, Fr 06-05 voll; 06-04 (Do) fehlt → 2
expect(
countStreakDays({
today: '2026-06-08',
fullDates: S('2026-06-08', '2026-06-05'),
absenceDates: NONE,
}),
).toBe(2);
});
it('Abwesenheit ist neutral (überspringen, nicht brechen)', () => {
// heute Mi 06-10 voll, Di 06-09 Urlaub, Mo 06-08 voll
expect(
countStreakDays({
today: '2026-06-10',
fullDates: S('2026-06-10', '2026-06-08'),
absenceDates: S('2026-06-09'),
}),
).toBe(2);
});
it('Feiertag bricht die Streak NICHT (Pfingstmontag 25.05.2026)', () => {
// heute Mi 05-27, Di 05-26 voll, Mo 05-25 Pfingstmontag, Fr 05-22 voll
expect(
countStreakDays({
today: '2026-05-27',
fullDates: S('2026-05-27', '2026-05-26', '2026-05-22'),
absenceDates: NONE,
}),
).toBe(3);
});
it('Reflexion am Wochenende zählt nicht zu 0 (zählt Freitag)', () => {
// heute Sa 06-06, voll am Sa + Fr 06-05; früher nichts
expect(
countStreakDays({
today: '2026-06-06',
fullDates: S('2026-06-06', '2026-06-05'),
absenceDates: NONE,
}),
).toBe(1);
});
it('leere Historie → 0', () => {
expect(countStreakDays({ today: '2026-06-10', fullDates: NONE, absenceDates: NONE })).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { isWerktag, previousWerktag } from './werktag.js';
/**
* Zählt aufeinanderfolgende Werktage (Mo-Fr, ohne Feiertage) mit voller
* Reflexion, rückwärts ab `today`.
*
* Regeln:
* - Nur Tage in `fullDates` (status = 'full') zählen.
* - Tage in `absenceDates` (Urlaub/Krank/…) sind neutral: überspringen, nicht
* zählen, nicht brechen.
* - Wochenenden und Feiertage sind keine Werktage → übersprungen.
* - Heute ist Schonfrist: ist heute noch nicht voll, wird ab dem vorherigen
* Werktag gezählt (die laufende Streak bleibt über den Tag erhalten).
* - Bricht beim ersten Werktag, der weder voll noch Abwesenheit ist.
*/
export function countStreakDays(opts: {
today: string;
fullDates: Set<string>;
absenceDates: Set<string>;
}): number {
const { today, fullDates, absenceDates } = opts;
let cursor = today;
let streak = 0;
// Heute noch nicht reflektiert → ab vorherigem Werktag zählen.
if (!fullDates.has(cursor)) cursor = previousWerktag(today);
while (true) {
// Nicht-Werktag (z. B. an einem Wochenende/Feiertag reflektiert) überspringen,
// statt die Streak fälschlich abzubrechen.
if (!isWerktag(cursor)) {
cursor = previousWerktag(cursor);
continue;
}
if (absenceDates.has(cursor)) {
cursor = previousWerktag(cursor);
continue;
}
if (fullDates.has(cursor)) {
streak++;
cursor = previousWerktag(cursor);
continue;
}
break;
}
return streak;
}
+10 -10
View File
@@ -7,24 +7,24 @@ describe('werktag', () => {
expect(formatIsoDate(d)).toBe('2026-05-25');
});
it('isWerktag erkennt Mo-Fr', () => {
expect(isWerktag('2026-05-25')).toBe(true); // Mo
expect(isWerktag('2026-05-29')).toBe(true); // Fr
expect(isWerktag('2026-05-30')).toBe(false); // Sa
expect(isWerktag('2026-05-31')).toBe(false); // So
it('isWerktag erkennt Mo-Fr (ohne Feiertage)', () => {
expect(isWerktag('2026-06-08')).toBe(true); // Mo
expect(isWerktag('2026-06-12')).toBe(true); // Fr
expect(isWerktag('2026-06-13')).toBe(false); // Sa
expect(isWerktag('2026-06-14')).toBe(false); // So
});
it('nextWerktag springt über Wochenende', () => {
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
expect(nextWerktag('2026-06-12')).toBe('2026-06-15'); // Fr → Mo
expect(nextWerktag('2026-06-08')).toBe('2026-06-09'); // Mo → Di
});
it('previousWerktag springt über Wochenende', () => {
expect(previousWerktag('2026-06-01')).toBe('2026-05-29'); // Mo → Fr
expect(previousWerktag('2026-06-15')).toBe('2026-06-12'); // Mo → Fr
});
it('werktageBetween zählt korrekt', () => {
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
// Mo 2026-06-08 bis Mo 2026-06-15 (exkl.): 5 Werktage
expect(werktageBetween('2026-06-08', '2026-06-15')).toBe(5);
});
});
+5 -2
View File
@@ -1,6 +1,8 @@
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
// (Frontend bekommt fertige Felder).
import { isGermanHoliday } from './holidays.js';
const TIMEZONE = 'Europe/Berlin';
/**
@@ -29,12 +31,13 @@ export function parseIsoDate(s: string): Date {
}
/**
* Mo-Fr = Werktag. Feiertage Phase 2.
* Werktag = Mo-Fr und kein bundesweiter gesetzlicher Feiertag.
*/
export function isWerktag(s: string): boolean {
const d = parseIsoDate(s);
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
return wd !== 'Sat' && wd !== 'Sun';
if (wd === 'Sat' || wd === 'Sun') return false;
return !isGermanHoliday(s);
}
export function nextWerktag(s: string): string {
+11 -35
View File
@@ -1,23 +1,20 @@
import { and, eq, gte, lte } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
import { user, dayEntry, absenceDay } from '@cockpit/db';
import type { Db } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import {
todayIso,
isWerktag,
previousWerktag,
parseIsoDate,
formatIsoDate,
} from '../lib/werktag.js';
import { todayIso, isWerktag, parseIsoDate, formatIsoDate } from '../lib/werktag.js';
import { countStreakDays } from '../lib/streak.js';
async function computeStreak(db: import('@cockpit/db').Db, userId: string): Promise<number> {
async function computeStreak(db: Db, userId: string): Promise<number> {
const today = todayIso();
// Hole alle relevanten DayEntries und Abwesenheiten der letzten 90 Tage.
// DayEntries + Abwesenheiten der letzten 400 Tage (deckt > 1 Jahr Werktage ab,
// damit lange Streaks nicht am Lookback-Fenster abgeschnitten werden).
const endIso = today;
const start = parseIsoDate(today);
start.setUTCDate(start.getUTCDate() - 120);
start.setUTCDate(start.getUTCDate() - 400);
const startIso = formatIsoDate(start);
const [entries, absences] = await Promise.all([
@@ -39,31 +36,11 @@ async function computeStreak(db: import('@cockpit/db').Db, userId: string): Prom
),
]);
const fullByDate = new Map<string, boolean>();
for (const e of entries) fullByDate.set(e.date, e.status === 'full');
const absSet = new Set(absences.map((a) => a.date));
const fullDates = new Set<string>();
for (const e of entries) if (e.status === 'full') fullDates.add(e.date);
const absenceDates = new Set(absences.map((a) => a.date));
// Zähle aufeinanderfolgende volle Werktage rückwärts ab heute.
let cursor = today;
let streak = 0;
// Wenn heute noch nicht voll: starten beim vorherigen Werktag.
if (!fullByDate.get(today)) {
cursor = previousWerktag(today);
}
while (true) {
if (!isWerktag(cursor)) break;
if (absSet.has(cursor)) {
cursor = previousWerktag(cursor);
continue;
}
if (fullByDate.get(cursor)) {
streak++;
cursor = previousWerktag(cursor);
continue;
}
break;
}
return streak;
return countStreakDays({ today, fullDates, absenceDates });
}
export const userRouter = router({
@@ -95,7 +72,6 @@ export const userRouter = router({
let cursor = input.from;
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
[];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (cursor <= input.to) {
if (isWerktag(cursor)) {
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });