fix(streak): Feiertage, Wochenend-Edge & Lookback-Deckel
- 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:
1 parent
1f0c9efd59
commit
bcae44eeb3
7 files changed
+256
-47
No files matched your search
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user