Files
dogfather-universe/server-internal/lib/webdesign-termine.js
T
DogFatherGitandClaude Opus 5 131bc8a755 Anfragen annehmen und ablehnen — mit laufender Zeit bis zur Übergabe
Annehmen war bisher nur ein Etikett: Man stellte den Status um, und es
passierte nichts. Kunde, Projekt, Aufgabenliste, Termin und Zugang musste
man danach von Hand in fünf Schritten nachbauen. Der alte Code gab das im
Kommentar selbst zu -- schlug der zweite von zwei Aufrufen fehl, stand der
Kunde schon in der Datenbank und man durfte es nicht noch einmal
versuchen.

Jetzt macht das EIN Aufruf, ganz oder gar nicht:
Kunde finden oder anlegen, Projekt anlegen, die komplette Aufgabenliste
des Pakets einsetzen, Liefertermin berechnen, Einladungslink erzeugen.
Der Kunde verfolgt ab diesem Moment alles in seinem Portal.

Die Zeit läuft wirklich:
- Eigenes Datumsfeld (termin_am) neben dem freien Text. Aus 'Mitte
  Oktober' kann man keine verbleibenden Tage rechnen.
- Werktage statt Kalendertage, inklusive luxemburgischer Feiertage. Die
  beweglichen werden über die Osterformel berechnet statt gepflegt --
  eine Liste ist im übernächsten Jahr lautlos falsch.
- Vorschlag je Paket (Onepager 10, Website 20, Shop 30 Werktage),
  überschreibbar vor dem Bestätigen.
- Verbleibende Zeit wird SERVERSEITIG gerechnet. Der Browser kennt die
  Feiertage nicht; zwei verschiedene Zahlen für denselben Termin wären
  schlimmer als gar keine.

Ablehnen mit Grund und vorbereiteter Absage in fünf Sprachen, Text
serverseitig erzeugt und vor dem Abschicken lesbar. Ein laufendes
Projekt lässt sich nicht nachträglich als Anfrage ablehnen.

Sechs Fehler dabei gefunden:
- Ein unbekanntes Paket hätte GAR KEINEN Termin bekommen statt des
  Ersatzwerts. Zwei Funktionen lasen dieselbe Tabelle, nur eine hatte
  einen Rückfallwert. Eine fehlende Zahl fällt nirgends auf.
- wd_anfragen hat keine Spalte 'firma' -- better-sqlite3 weist undefined
  ab, die ganze Annahme wäre gescheitert.
- Migrationen stehen in einer ausdrücklichen Liste; 0015 fehlte darin.
- datumKurz() wurde aufgerufen, gab es aber nicht. Der Fehler wäre erst
  NACH dem Anlegen aufgetreten.
- Der Installations-Hinweis lag fest über dem Annehmen-Knopf und machte
  ihn auf dem Handy untreffbar. Die Seite reserviert jetzt Platz dafür.
- 'richttermin' ist freier Text, lief aber durch einen Datumsformatierer:
  Der Kunde las 'Invalid Date' an der wichtigsten Stelle seines Projekts.

Geprüft: 49 Prüfungen der Terminrechnung gegen nachschlagbare Osterdaten
und Wochentage, 70 gegen eine echte Datenbank (darunter: zweiter Klick
legt nichts doppelt an, Abbruch mittendrin lässt NICHTS zurück, gesperrter
Kunde wird nicht still entsperrt), 42 im Browser auf Computer und Handy,
40 im Portal. Alle bestehenden Prüfungen weiter grün.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 22:44:08 +02:00

205 lines
8.1 KiB
JavaScript

/* =====================================================================
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,
};
}