/* ===================================================================== webdesign-termine.js — Laufzeiten, Werktage, Liefertermine Hier entsteht die Zahl, die der Kunde als Zusage liest. Deshalb wird sie nicht überschlagen, sondern gerechnet. DREI ENTSCHEIDUNGEN, DIE MAN BEGRÜNDEN MUSS 1. WERKTAGE, NICHT KALENDERTAGE. "In zehn Tagen" heißt bei zehn Kalendertagen: zwei Wochenenden sind schon weg, es bleiben sechs Arbeitstage. Wer so zusagt, sagt etwas zu, das er nicht halten kann. Gerechnet wird deshalb in Werktagen, und das Ergebnis ist ein Kalenderdatum. 2. FEIERTAGE ZÄHLEN MIT. Ein Liefertermin, der auf den luxemburgischen Nationalfeiertag fällt, ist keine Zusage, sondern ein Versehen. Die beweglichen Feiertage hängen am Osterdatum und werden deshalb berechnet, nicht als Liste gepflegt — eine Liste ist spätestens im übernächsten Jahr falsch, und zwar lautlos. 3. DIE LAUFZEITEN STEHEN IM CODE, NICHT IN DER DATENBANK. Sie sind eine Geschäftsentscheidung, keine Nutzereingabe. In der Datenbank müsste man sie pflegen, könnte sie versehentlich leeren, und ein leeres Feld ergäbe einen Termin von heute. Im Code sind sie überprüfbar und können nicht verschwinden. Abweichen kann man trotzdem: Der vorgeschlagene Termin ist beim Annehmen überschreibbar. ===================================================================== */ /* Vorgesehene Laufzeit je Paket, in WERKTAGEN. Die Zahlen sind bewusst großzügig gewählt. Ein Termin, den man regelmäßig reißt, richtet mehr Schaden an als einer, der von Anfang an ehrlich ist -- und wer früher fertig wird, macht damit eine Freude statt einer Entschuldigung. */ export const LAUFZEIT_WERKTAGE = { onepager: 10, // eine Seite, klarer Umfang website: 20, // mehrere Seiten, Abstimmungsschleifen shop: 30, // Produkte, Bezahlung, Versand, Rechtstexte verwaltung: 15, // Adminbereich auf bestehender Grundlage betreuung: 0, // laufend -- hat keinen Liefertermin (siehe unten) unklar: 20, // bis geklärt ist, was es wird: mittlerer Wert }; /* Die Laufzeit eines Pakets -- an EINER Stelle aufgeloest. Vorher lasen zwei Funktionen die Tabelle getrennt aus, und nur eine von beiden hatte einen Ersatzwert. Ein unbekanntes Paket bekam dadurch gar keinen Termin statt des mittleren Werts: Das Projekt lief ohne Zusage weiter, und niemand haette es bemerkt, weil eine fehlende Zahl nirgends auffaellt. Aufgefallen erst durch den Test. Deshalb gibt es genau einen Ort, an dem aus einem Paket eine Zahl wird -- und nur der Sonderfall "laufende Betreuung" ergibt null. */ export function laufzeitFuer(paket) { if (paket === "betreuung") return 0; // laufend, kein Ende const wert = LAUFZEIT_WERKTAGE[paket]; return Number.isFinite(wert) ? wert : LAUFZEIT_WERKTAGE.unklar; } /* Pakete ohne Liefertermin. Eine laufende Betreuung ist nie "fertig"; ein Countdown darauf wäre sinnlos und würde im Portal irgendwann "abgelaufen" anzeigen, obwohl alles in Ordnung ist. */ export function hatLiefertermin(paket) { return laufzeitFuer(paket) > 0; } /* --------------------------------------------------------------------- Ostersonntag nach der Gaußschen Osterformel (gregorianisch). Gebraucht für Ostermontag, Christi Himmelfahrt und Pfingstmontag -- die drei beweglichen Feiertage. Die Formel gilt für 1583 bis 2299, was für Liefertermine reichlich genügt. --------------------------------------------------------------------- */ function ostersonntag(jahr) { const a = jahr % 19; const b = Math.floor(jahr / 100); const c = jahr % 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 monat = Math.floor((h + l - 7 * m + 114) / 31); // 3 = März, 4 = April const tag = ((h + l - 7 * m + 114) % 31) + 1; return new Date(Date.UTC(jahr, monat - 1, tag)); } function plusTage(datum, n) { const d = new Date(datum.getTime()); d.setUTCDate(d.getUTCDate() + n); return d; } const alsSchluessel = (d) => d.toISOString().slice(0, 10); /* Gesetzliche Feiertage in Luxemburg. Warum Luxemburg: Dort sitzt der Betrieb, danach richtet sich, wann gearbeitet wird. Der Wohnort des Kunden ändert daran nichts -- wer in Deutschland bestellt, bekommt trotzdem am 23. Juni keine Arbeit geliefert. */ const feiertagsSpeicher = new Map(); export function feiertage(jahr) { if (feiertagsSpeicher.has(jahr)) return feiertagsSpeicher.get(jahr); const o = ostersonntag(jahr); const tage = new Set([ alsSchluessel(new Date(Date.UTC(jahr, 0, 1))), // Neujahr alsSchluessel(plusTage(o, 1)), // Ostermontag alsSchluessel(new Date(Date.UTC(jahr, 4, 1))), // Tag der Arbeit alsSchluessel(new Date(Date.UTC(jahr, 4, 9))), // Europatag alsSchluessel(plusTage(o, 39)), // Christi Himmelfahrt alsSchluessel(plusTage(o, 50)), // Pfingstmontag alsSchluessel(new Date(Date.UTC(jahr, 5, 23))), // Nationalfeiertag alsSchluessel(new Date(Date.UTC(jahr, 7, 15))), // Mariä Himmelfahrt alsSchluessel(new Date(Date.UTC(jahr, 10, 1))), // Allerheiligen alsSchluessel(new Date(Date.UTC(jahr, 11, 25))), // Weihnachten alsSchluessel(new Date(Date.UTC(jahr, 11, 26))), // Zweiter Weihnachtstag ]); feiertagsSpeicher.set(jahr, tage); return tage; } export function istWerktag(datum) { const wt = datum.getUTCDay(); if (wt === 0 || wt === 6) return false; // Sonntag, Samstag return !feiertage(datum.getUTCFullYear()).has(alsSchluessel(datum)); } /** * Zählt `werktage` Werktage ab `ab` und liefert das Kalenderdatum. * * Der Starttag selbst zählt NICHT mit. Wer am Montag zusagt und * "1 Werktag" vorsieht, meint Dienstag -- nicht "heute noch". * * @param {Date|string} ab Startpunkt (Datum oder ISO-Text) * @param {number} werktage Anzahl Werktage * @returns {string|null} Datum als JJJJ-MM-TT, oder null */ export function werktageAddieren(ab, werktage) { const n = Number(werktage); if (!Number.isFinite(n) || n <= 0) return null; /* Auf UTC-Mitternacht normieren. Ohne das schleppt man die Uhrzeit mit, und ein Termin könnte je nach Tageszeit einen Tag springen. */ const start = new Date(ab); if (isNaN(start)) return null; let d = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())); let offen = Math.min(n, 500); // Deckel gegen eine Endlosschleife while (offen > 0) { d = plusTage(d, 1); if (istWerktag(d)) offen--; } return alsSchluessel(d); } /** * Verbleibende WERKTAGE bis zu einem Termin. * * Bewusst Werktage und nicht Kalendertage: "Noch 2 Tage" am Freitag * klingt nach Montag, gemeint ist aber Dienstag. Wer danach plant, * plant falsch. * * @returns {number} negativ, wenn der Termin überschritten ist */ export function werktageBis(termin, vonHeute) { if (!termin) return null; const ziel = new Date(termin + "T00:00:00Z"); if (isNaN(ziel)) return null; const jetzt = vonHeute ? new Date(vonHeute) : new Date(); let d = new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate())); if (alsSchluessel(d) === termin) return 0; const rueckwaerts = ziel < d; let zahl = 0; let schutz = 0; while (alsSchluessel(d) !== termin && schutz++ < 2000) { d = plusTage(d, rueckwaerts ? -1 : 1); if (istWerktag(d)) zahl++; } return rueckwaerts ? -zahl : zahl; } /** * Vorschlag für einen Liefertermin beim Annehmen einer Anfrage. * * @returns {{termin: string|null, werktage: number, laufend: boolean}} */ export function terminVorschlag(paket, ab) { const werktage = laufzeitFuer(paket); if (werktage <= 0) return { termin: null, werktage: 0, laufend: true }; return { termin: werktageAddieren(ab || new Date(), werktage), werktage, laufend: false, }; }