diff --git a/apps/api/src/lib/holidays.test.ts b/apps/api/src/lib/holidays.test.ts new file mode 100644 index 0000000..14a6b5a --- /dev/null +++ b/apps/api/src/lib/holidays.test.ts @@ -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 + }); +}); diff --git a/apps/api/src/lib/holidays.ts b/apps/api/src/lib/holidays.ts new file mode 100644 index 0000000..defff1f --- /dev/null +++ b/apps/api/src/lib/holidays.ts @@ -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>(); + +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 { + const cached = cache.get(year); + if (cached) return cached; + const e = easterSunday(year); + const set = new Set([ + 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); +} diff --git a/apps/api/src/lib/streak.test.ts b/apps/api/src/lib/streak.test.ts new file mode 100644 index 0000000..e130f01 --- /dev/null +++ b/apps/api/src/lib/streak.test.ts @@ -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(); + +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); + }); +}); diff --git a/apps/api/src/lib/streak.ts b/apps/api/src/lib/streak.ts new file mode 100644 index 0000000..ed5d960 --- /dev/null +++ b/apps/api/src/lib/streak.ts @@ -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; + absenceDates: Set; +}): 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; +} diff --git a/apps/api/src/lib/werktag.test.ts b/apps/api/src/lib/werktag.test.ts index ba3655e..0d48270 100644 --- a/apps/api/src/lib/werktag.test.ts +++ b/apps/api/src/lib/werktag.test.ts @@ -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); }); }); diff --git a/apps/api/src/lib/werktag.ts b/apps/api/src/lib/werktag.ts index 8105e3b..f93bdad 100644 --- a/apps/api/src/lib/werktag.ts +++ b/apps/api/src/lib/werktag.ts @@ -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 { diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 01d85f5..25aa6fc 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -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 { +async function computeStreak(db: Db, userId: string): Promise { 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(); - 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(); + 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 });