From 2636ce60be0181489775a66062912e58c2321c92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:36:37 +0000 Subject: [PATCH] =?UTF-8?q?Feiertage:=20Namen=20und=20Pr=C3=BCftage=20im?= =?UTF-8?q?=20Zeitraum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Exporte feiertagName und feiertageImZeitraum (feiertagejs getHolidays je Jahr, Namen per translate('de')). Heiligabend, Silvester, Mariä Himmelfahrt in BY und Fronleichnam in SN/TH (Datum aus RP) sind 'pruefen'; istFeiertag bleibt unverändert. Co-Authored-By: Claude Opus 5.5 --- server/services/termine/feiertage.js | 71 ++++++++++- test/zeitenFeiertage.test.js | 183 +++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 test/zeitenFeiertage.test.js diff --git a/server/services/termine/feiertage.js b/server/services/termine/feiertage.js index 1e8a881..7445149 100644 --- a/server/services/termine/feiertage.js +++ b/server/services/termine/feiertage.js @@ -1,4 +1,4 @@ -import { isHoliday } from 'feiertagejs' +import { getHolidays, isHoliday } from 'feiertagejs' /** * Gesetzliche Feiertage je Bundesland (feiertagejs 1.5.1, Tage als Berliner Kalendertage). @@ -24,3 +24,72 @@ export function istFeiertag(tag, land) { return false } } + +/** + * Feiertage mit Namen und „bitte pruefen“ (Oeffnungszeiten live). + * + * art 'feiertag': gesetzlicher Feiertag des Landes (feiertagejs; in BB auch Oster- und Pfingstsonntag). + * art 'pruefen': Tag, den der Kunde selbst bedenken muss, weil er nicht ueberall oder nicht gesetzlich gilt: + * Heiligabend, Silvester, Mariae Himmelfahrt in BY (nur oertlich) und Fronleichnam in SN und TH (dort nur + * in einzelnen Gemeinden; das Datum kommt aus der RP-Liste). Ist ein solcher Tag im Land zugleich + * gesetzlicher Feiertag (Mariae Himmelfahrt im SL, Fronleichnam in RP), bleibt er 'feiertag'. + */ + +const PRUEFEN_LAENDER = Object.freeze({ + MARIAHIMMELFAHRT: Object.freeze(['BY']), +}) +const FRONLEICHNAM_ORTSABHAENGIG = Object.freeze(['SN', 'TH']) + +/** Alle Eintraege eines Kalenderjahres fuer ein (gueltiges) Land, nach Tag sortiert, ein Eintrag je Tag. */ +function jahresEintraege(jahr, land) { + let liste + try { + liste = getHolidays(jahr, land).map((h) => ({ + tag: h.dateString, + name: h.translate('de'), + art: PRUEFEN_LAENDER[h.name]?.includes(land) ? 'pruefen' : 'feiertag', + })) + if (FRONLEICHNAM_ORTSABHAENGIG.includes(land)) { + const rp = getHolidays(jahr, 'RP').find((h) => h.name === 'FRONLEICHNAM') + if (rp && !liste.some((e) => e.tag === rp.dateString)) { + liste.push({ tag: rp.dateString, name: rp.translate('de'), art: 'pruefen' }) + } + } + } catch { + return [] + } + const jj = String(jahr).padStart(4, '0') + for (const [tag, name] of [[`${jj}-12-24`, 'Heiligabend'], [`${jj}-12-31`, 'Silvester']]) { + if (!liste.some((e) => e.tag === tag)) liste.push({ tag, name, art: 'pruefen' }) + } + return liste.sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0)) +} + +/** + * Name des gesetzlichen Feiertags (z. B. 'Tag der Deutschen Einheit') oder null. + * Nur art 'feiertag': Pruefe-Tage (Heiligabend, Mariae Himmelfahrt in BY, ...) liefert nur feiertageImZeitraum. + * (istFeiertag fuehrt Mariae Himmelfahrt in BY weiterhin als Feiertag, das bleibt unveraendert.) + * Wirft nie: ungueltiger Tag oder unbekanntes Land ergeben null. + */ +export function feiertagName(tag, land) { + if (typeof tag !== 'string' || !TAG_RE.test(tag) || !BUNDESLAENDER.includes(land)) return null + const e = jahresEintraege(Number(tag.slice(0, 4)), land).find((x) => x.tag === tag) + return e && e.art === 'feiertag' ? e.name : null +} + +/** + * Feiertage und Pruefe-Tage von bis (beide 'YYYY-MM-DD', inklusive), sortiert, ueber Jahresgrenzen. + * Ergebnis: [{ tag, name, art: 'feiertag' | 'pruefen' }]. Ungueltige Eingaben, unbekanntes Land oder + * bis vor von ergeben []. Je Kalenderjahr ein getHolidays-Aufruf (nicht istFeiertag je Tag). + */ +export function feiertageImZeitraum(von, bis, land) { + if (typeof von !== 'string' || typeof bis !== 'string') return [] + if (!TAG_RE.test(von) || !TAG_RE.test(bis) || von > bis || !BUNDESLAENDER.includes(land)) return [] + const ergebnis = [] + for (let jahr = Number(von.slice(0, 4)); jahr <= Number(bis.slice(0, 4)); jahr += 1) { + for (const e of jahresEintraege(jahr, land)) { + if (e.tag >= von && e.tag <= bis) ergebnis.push(e) + } + } + return ergebnis +} diff --git a/test/zeitenFeiertage.test.js b/test/zeitenFeiertage.test.js new file mode 100644 index 0000000..5c12707 --- /dev/null +++ b/test/zeitenFeiertage.test.js @@ -0,0 +1,183 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + BUNDESLAENDER, + istFeiertag, + feiertagName, + feiertageImZeitraum, +} from '../server/services/termine/feiertage.js' + +const eintrag = (liste, tag) => liste.find((e) => e.tag === tag) + +test('Tag der Deutschen Einheit hat einen Namen', () => { + assert.equal(feiertagName('2026-10-03', 'RP'), 'Tag der Deutschen Einheit') +}) + +test('normaler Tag hat keinen Namen', () => { + assert.equal(feiertagName('2026-10-05', 'RP'), null) +}) + +test('ungültiges Land wirft nicht', () => { + assert.equal(feiertagName('2026-10-03', 'XX'), null) + assert.equal(feiertagName('2026-10-03', 'rp'), null, 'Kuerzel nur in Grossbuchstaben wie BUNDESLAENDER') + assert.equal(feiertagName('2026-10-03', ''), null) + assert.equal(feiertagName('2026-10-03', undefined), null) + assert.deepEqual(feiertageImZeitraum('2026-10-01', '2026-10-31', 'XX'), []) + assert.deepEqual(feiertageImZeitraum('2026-10-01', '2026-10-31', undefined), []) +}) + +test('ungültiger Tag wirft nicht', () => { + for (const tag of [undefined, null, 5, '', '2026-10-3', '03.10.2026', '2026-13-45', '0000-00-00']) { + assert.equal(feiertagName(tag, 'RP'), null, String(tag)) + } + assert.deepEqual(feiertageImZeitraum('kaputt', '2026-10-31', 'RP'), []) + assert.deepEqual(feiertageImZeitraum('2026-10-01', null, 'RP'), []) + assert.deepEqual(feiertageImZeitraum(undefined, undefined, 'RP'), []) +}) + +test('Heiligabend und Silvester sind „pruefen“', () => { + const liste = feiertageImZeitraum('2026-12-20', '2027-01-02', 'RP') + assert.deepEqual(eintrag(liste, '2026-12-24'), { tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' }) + assert.deepEqual(eintrag(liste, '2026-12-31'), { tag: '2026-12-31', name: 'Silvester', art: 'pruefen' }) + assert.deepEqual(eintrag(liste, '2027-01-01'), { tag: '2027-01-01', name: 'Neujahrstag', art: 'feiertag' }) +}) + +test('Jahreswechsel: beide Jahre werden geliefert', () => { + const liste = feiertageImZeitraum('2026-12-20', '2027-01-02', 'RP') + const tage = liste.map((e) => e.tag) + assert.deepEqual(tage, ['2026-12-24', '2026-12-25', '2026-12-26', '2026-12-31', '2027-01-01']) + assert.equal(eintrag(liste, '2026-12-25').art, 'feiertag') + assert.equal(eintrag(liste, '2026-12-26').art, 'feiertag') +}) + +test('Zeitraum über mehrere Jahre: sortiert, ohne Doppelte', () => { + const liste = feiertageImZeitraum('2026-10-01', '2028-02-01', 'BY') + const tage = liste.map((e) => e.tag) + assert.deepEqual(tage, [...tage].sort()) + assert.equal(new Set(tage).size, tage.length) + assert.ok(tage.includes('2027-08-15')) + assert.ok(tage.includes('2027-01-06')) + assert.ok(!tage.includes('2026-08-15'), 'vor dem Zeitraum') + assert.ok(!tage.includes('2028-04-14'), 'nach dem Zeitraum') +}) + +test('Grenzen sind eingeschlossen', () => { + assert.deepEqual(feiertageImZeitraum('2026-10-03', '2026-10-03', 'RP'), [ + { tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }, + ]) + const liste = feiertageImZeitraum('2026-12-25', '2026-12-26', 'RP') + assert.deepEqual(liste.map((e) => e.tag), ['2026-12-25', '2026-12-26']) + assert.deepEqual(feiertageImZeitraum('2026-10-04', '2026-10-30', 'RP'), []) +}) + +test('Zeitraum mit bis vor von ist leer', () => { + assert.deepEqual(feiertageImZeitraum('2026-10-31', '2026-10-01', 'RP'), []) +}) + +test('Mariä Himmelfahrt in BY ist „pruefen“, im SL „feiertag“', () => { + const by = eintrag(feiertageImZeitraum('2028-08-15', '2028-08-15', 'BY'), '2028-08-15') + const sl = eintrag(feiertageImZeitraum('2028-08-15', '2028-08-15', 'SL'), '2028-08-15') + assert.deepEqual(by, { tag: '2028-08-15', name: 'Mariä Himmelfahrt', art: 'pruefen' }) + assert.deepEqual(sl, { tag: '2028-08-15', name: 'Mariä Himmelfahrt', art: 'feiertag' }) + assert.equal(eintrag(feiertageImZeitraum('2028-08-15', '2028-08-15', 'RP'), '2028-08-15'), undefined) +}) + +test('Fronleichnam in SN und TH ist „pruefen“ (Datum aus RP)', () => { + for (const land of ['SN', 'TH']) { + const e = eintrag(feiertageImZeitraum('2026-06-01', '2026-06-30', land), '2026-06-04') + assert.deepEqual(e, { tag: '2026-06-04', name: 'Fronleichnam', art: 'pruefen' }, land) + } + for (const land of ['RP', 'BY', 'BW', 'HE', 'NW', 'SL']) { + const e = eintrag(feiertageImZeitraum('2026-06-01', '2026-06-30', land), '2026-06-04') + assert.deepEqual(e, { tag: '2026-06-04', name: 'Fronleichnam', art: 'feiertag' }, land) + } + for (const land of ['BE', 'HH', 'NI', 'SH']) { + assert.equal(eintrag(feiertageImZeitraum('2026-06-01', '2026-06-30', land), '2026-06-04'), undefined, land) + } +}) + +test('Fronleichnam in SN folgt dem Osterdatum jedes Jahres', () => { + // Ostersonntag + 60 Tage: 2027-05-27, 2028-06-15 + assert.equal(eintrag(feiertageImZeitraum('2027-01-01', '2027-12-31', 'SN'), '2027-05-27').art, 'pruefen') + assert.equal(eintrag(feiertageImZeitraum('2028-01-01', '2028-12-31', 'TH'), '2028-06-15').art, 'pruefen') +}) + +test('in BB bleiben Oster- und Pfingstsonntag echte Feiertage', () => { + const liste = feiertageImZeitraum('2028-04-01', '2028-06-30', 'BB') + assert.deepEqual(eintrag(liste, '2028-04-16'), { tag: '2028-04-16', name: 'Ostersonntag', art: 'feiertag' }) + assert.deepEqual(eintrag(liste, '2028-06-04'), { tag: '2028-06-04', name: 'Pfingstsonntag', art: 'feiertag' }) +}) + +test('landesweite Feiertage anderer Länder bleiben „feiertag“', () => { + assert.equal(eintrag(feiertageImZeitraum('2028-11-01', '2028-11-30', 'SN'), '2028-11-22').name, 'Buß- und Bettag') + assert.equal(eintrag(feiertageImZeitraum('2028-11-01', '2028-11-30', 'SN'), '2028-11-22').art, 'feiertag') + assert.equal(eintrag(feiertageImZeitraum('2028-01-01', '2028-01-31', 'BY'), '2028-01-06').name, 'Heilige Drei Könige') +}) + +test('feiertagName liefert nur echte Feiertage, keine Prüftage', () => { + assert.equal(feiertagName('2026-12-24', 'RP'), null, 'Heiligabend ist kein gesetzlicher Feiertag') + assert.equal(feiertagName('2026-12-31', 'RP'), null) + assert.equal(feiertagName('2028-08-15', 'BY'), null, 'in BY nur örtlich') + assert.equal(feiertagName('2028-08-15', 'SL'), 'Mariä Himmelfahrt') + assert.equal(feiertagName('2026-06-04', 'SN'), null) + assert.equal(feiertagName('2026-06-04', 'RP'), 'Fronleichnam') + assert.equal(feiertagName('2026-12-25', 'HH'), '1. Weihnachtstag') +}) + +test('feiertagName stimmt mit istFeiertag überein (alle Länder, drei Jahre)', () => { + for (const land of BUNDESLAENDER) { + const liste = feiertageImZeitraum('2026-01-01', '2028-12-31', land).filter((e) => e.art === 'feiertag') + assert.ok(liste.length >= 9, land) + for (const e of liste) { + assert.equal(istFeiertag(e.tag, land), true, `${land} ${e.tag}`) + assert.equal(feiertagName(e.tag, land), e.name, `${land} ${e.tag}`) + } + } + // Gegenrichtung: ein ganzes Jahr, jeder Tag ohne Eintrag ist auch für istFeiertag kein Feiertag + const rp = new Set(feiertageImZeitraum('2027-01-01', '2027-12-31', 'RP').filter((e) => e.art === 'feiertag').map((e) => e.tag)) + for (let m = 1; m <= 12; m += 1) { + for (let d = 1; d <= 28; d += 1) { + const tag = `2027-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` + assert.equal(feiertagName(tag, 'RP') !== null, rp.has(tag), tag) + assert.equal(istFeiertag(tag, 'RP'), rp.has(tag), tag) + } + } +}) + +test('Prüftage doppeln keinen Eintrag und sind sortiert', () => { + for (const land of BUNDESLAENDER) { + const liste = feiertageImZeitraum('2026-01-01', '2029-12-31', land) + const tage = liste.map((e) => e.tag) + assert.equal(new Set(tage).size, tage.length, `${land}: ein Eintrag je Tag`) + assert.deepEqual(tage, [...tage].sort(), `${land}: sortiert`) + for (const e of liste) { + assert.match(e.name, /\S/) + assert.ok(e.art === 'feiertag' || e.art === 'pruefen') + } + for (const e of liste.filter((x) => x.art === 'pruefen')) { + assert.equal(feiertagName(e.tag, land), null, `${land} ${e.tag}`) + // istFeiertag (Terminplaner) bleibt unveraendert: nur Mariae Himmelfahrt in BY fuehrt es als Feiertag + if (!(land === 'BY' && e.name === 'Mariä Himmelfahrt')) { + assert.equal(istFeiertag(e.tag, land), false, `${land} ${e.tag}`) + } + } + } + assert.equal(istFeiertag('2028-08-15', 'BY'), true, 'bisheriges Verhalten von istFeiertag bleibt') +}) + +test('Ergebnis ist unabhängig von der Prozess-Zeitzone (UTC bis UTC+9, UTC-8)', async () => { + const { execFileSync } = await import('node:child_process') + const code = ` + import { feiertageImZeitraum } from './server/services/termine/feiertage.js' + process.stdout.write(JSON.stringify(feiertageImZeitraum('2026-12-20', '2027-01-02', 'SN'))) + ` + const lauf = (tz) => execFileSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, TZ: tz }, + encoding: 'utf8', + }) + const erwartet = JSON.stringify(feiertageImZeitraum('2026-12-20', '2027-01-02', 'SN')) + for (const tz of ['UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Asia/Tokyo']) { + assert.equal(lauf(tz), erwartet, tz) + } +})