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');
|
expect(formatIsoDate(d)).toBe('2026-05-25');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isWerktag erkennt Mo-Fr', () => {
|
it('isWerktag erkennt Mo-Fr (ohne Feiertage)', () => {
|
||||||
expect(isWerktag('2026-05-25')).toBe(true); // Mo
|
expect(isWerktag('2026-06-08')).toBe(true); // Mo
|
||||||
expect(isWerktag('2026-05-29')).toBe(true); // Fr
|
expect(isWerktag('2026-06-12')).toBe(true); // Fr
|
||||||
expect(isWerktag('2026-05-30')).toBe(false); // Sa
|
expect(isWerktag('2026-06-13')).toBe(false); // Sa
|
||||||
expect(isWerktag('2026-05-31')).toBe(false); // So
|
expect(isWerktag('2026-06-14')).toBe(false); // So
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nextWerktag springt über Wochenende', () => {
|
it('nextWerktag springt über Wochenende', () => {
|
||||||
expect(nextWerktag('2026-05-29')).toBe('2026-06-01'); // Fr → Mo
|
expect(nextWerktag('2026-06-12')).toBe('2026-06-15'); // Fr → Mo
|
||||||
expect(nextWerktag('2026-05-25')).toBe('2026-05-26'); // Mo → Di
|
expect(nextWerktag('2026-06-08')).toBe('2026-06-09'); // Mo → Di
|
||||||
});
|
});
|
||||||
|
|
||||||
it('previousWerktag springt über Wochenende', () => {
|
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', () => {
|
it('werktageBetween zählt korrekt', () => {
|
||||||
// Mo 2026-05-25 bis Mo 2026-06-01 (exkl.): 5 Werktage
|
// Mo 2026-06-08 bis Mo 2026-06-15 (exkl.): 5 Werktage
|
||||||
expect(werktageBetween('2026-05-25', '2026-06-01')).toBe(5);
|
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
|
// Werktag-Logik in Europe/Berlin. Single source of truth fürs Backend
|
||||||
// (Frontend bekommt fertige Felder).
|
// (Frontend bekommt fertige Felder).
|
||||||
|
|
||||||
|
import { isGermanHoliday } from './holidays.js';
|
||||||
|
|
||||||
const TIMEZONE = 'Europe/Berlin';
|
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 {
|
export function isWerktag(s: string): boolean {
|
||||||
const d = parseIsoDate(s);
|
const d = parseIsoDate(s);
|
||||||
const wd = new Intl.DateTimeFormat('en-US', { timeZone: TIMEZONE, weekday: 'short' }).format(d);
|
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 {
|
export function nextWerktag(s: string): string {
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { and, eq, gte, lte } from 'drizzle-orm';
|
import { and, eq, gte, lte } from 'drizzle-orm';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { user, dayEntry, absenceDay } from '@cockpit/db';
|
import { user, dayEntry, absenceDay } from '@cockpit/db';
|
||||||
|
import type { Db } from '@cockpit/db';
|
||||||
import { DayEntrySchemas } from '@cockpit/shared';
|
import { DayEntrySchemas } from '@cockpit/shared';
|
||||||
import { router, protectedProcedure } from '../trpc.js';
|
import { router, protectedProcedure } from '../trpc.js';
|
||||||
import {
|
import { todayIso, isWerktag, parseIsoDate, formatIsoDate } from '../lib/werktag.js';
|
||||||
todayIso,
|
import { countStreakDays } from '../lib/streak.js';
|
||||||
isWerktag,
|
|
||||||
previousWerktag,
|
|
||||||
parseIsoDate,
|
|
||||||
formatIsoDate,
|
|
||||||
} from '../lib/werktag.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();
|
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 endIso = today;
|
||||||
const start = parseIsoDate(today);
|
const start = parseIsoDate(today);
|
||||||
start.setUTCDate(start.getUTCDate() - 120);
|
start.setUTCDate(start.getUTCDate() - 400);
|
||||||
const startIso = formatIsoDate(start);
|
const startIso = formatIsoDate(start);
|
||||||
|
|
||||||
const [entries, absences] = await Promise.all([
|
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>();
|
const fullDates = new Set<string>();
|
||||||
for (const e of entries) fullByDate.set(e.date, e.status === 'full');
|
for (const e of entries) if (e.status === 'full') fullDates.add(e.date);
|
||||||
const absSet = new Set(absences.map((a) => a.date));
|
const absenceDates = new Set(absences.map((a) => a.date));
|
||||||
|
|
||||||
// Zähle aufeinanderfolgende volle Werktage rückwärts ab heute.
|
return countStreakDays({ today, fullDates, absenceDates });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userRouter = router({
|
export const userRouter = router({
|
||||||
@@ -95,7 +72,6 @@ export const userRouter = router({
|
|||||||
let cursor = input.from;
|
let cursor = input.from;
|
||||||
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
|
const toInsert: { userId: string; date: string; reason: 'urlaub' | 'krank' | 'sonstiges' }[] =
|
||||||
[];
|
[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
while (cursor <= input.to) {
|
while (cursor <= input.to) {
|
||||||
if (isWerktag(cursor)) {
|
if (isWerktag(cursor)) {
|
||||||
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });
|
toInsert.push({ userId: ctx.user.id, date: cursor, reason: input.reason });
|
||||||
|
|||||||
Reference in new issue
Block a user