/* ===================================================================== pruef-tagesgrenze.mjs — der Tageswechsel, zu jeder Uhrzeit prüfbar --------------------------------------------------------------------- DAS PROBLEM MIT DEN PRÜFUNGEN VON GESTERN Am 01.10.2026 wurde ein Fehler gefunden, der sechzehn Stellen im Haus betraf: Ein Kalendertag wurde aus UTC gebildet statt aus der Ortszeit. Zwischen 00:00 und 02:00 (im Winter 00:00 bis 01:00) liegt der UTC-Tag einen zurück — ein Termin, nachts angelegt, trug gestern und landete auf dem Brett unter „Vorbei", wo er zugeklappt ist. Repariert, und mit `pruef-kreislauf` abgesichert: ok und er liegt HEUTE, nicht gestern (…) ABER: Diese Zeile kann den Fehler nur ZWEI STUNDEN AM TAG überhaupt sehen. Den Rest des Tages sind Ortszeit und UTC-Tag derselbe, und die Behauptung ist wahr, ohne irgendetwas zu beweisen. Genau die Sorte grüner Haken, vor der dieses Haus an mehreren Stellen warnt: Sie läuft, sie ist grün, und sie ist wertlos. Gemerkt habe ich es, weil ich die Reparatur am 01.10. zwischen 10 und 16 Uhr geprüft habe — also zu einer Zeit, in der der Fehler gar nicht auftreten KONNTE. Erst um 00:09 des 02.10. war sie wirklich bewiesen. --------------------------------------------------------------------- WAS DIESE PRÜFUNG ANDERS MACHT Sie wartet nicht auf Mitternacht, sie verschiebt die Uhr. Node übernimmt eine zur Laufzeit gesetzte `TZ` (nachgemessen: Stunde 0 wird zu Stunde 12 unter `Etc/GMT-14`). WELCHE ZEITZONE, HÄNGT VON DER UHRZEIT AB. Es gibt keine, in der sich Orts- und UTC-Tag IMMER unterscheiden — dafür bräuchte es 24 Stunden Versatz. Also wird die passende gewählt: UTC-Stunde ab 10 Uhr -> Etc/GMT-14 (UTC+14, Ortstag voraus) UTC-Stunde vor 10 Uhr -> Etc/GMT+11 (UTC−11, Ortstag zurück) Bei 10 Uhr UTC gehen beide; die Grenze ist deshalb kein scharfer Rand, an dem etwas kippen kann. UND DAS WIRD NACHGESEHEN, NICHT ANGENOMMEN: Unterscheiden sich die zwei Tage in diesem Lauf wider Erwarten nicht, bricht die Prüfung mit dem dritten Ausgang ab. Eine Prüfung, die ihre eigene Voraussetzung nicht hat, darf nicht grün melden — daran ist am 03.09.2026 die Gitea-Pflichtprüfung wochenlang vorbeigelaufen. Aufrufen mit: node server/pruef-tagesgrenze.mjs ===================================================================== */ /* ==== DIE ZEITZONE MUSS VOR ALLEM ANDEREN STEHEN ==================== Sie wirkt auf jedes `new Date()` danach — auch auf die im Server, der gleich geladen wird. Stünde sie weiter unten, hätte der Server schon seine Vorstellung vom heutigen Tag. */ const UTC_STUNDE = new Date().getUTCHours(); const ZONE = UTC_STUNDE >= 10 ? "Etc/GMT-14" : "Etc/GMT+11"; process.env.TZ = ZONE; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eigenerPort } from "./helfer-port.mjs"; import { heuteLokal, tagLokal } from "./helfer-tag.mjs"; let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* ---- Die Voraussetzung, bevor irgendetwas anderes passiert -------- */ console.log(`\n=== Die verschobene Uhr (${ZONE}) ===`); const ORTSTAG = heuteLokal(); const UTC_TAG = new Date().toISOString().slice(0, 10); console.log(` Ortstag ${ORTSTAG} · UTC-Tag ${UTC_TAG} · UTC-Stunde ${UTC_STUNDE}`); if (ORTSTAG === UTC_TAG) { /* DRITTER AUSGANG. Ohne Unterschied prüft diese Datei nichts, und das muss sie sagen statt grün zu melden. */ console.log(" -- KONNTE NICHT NACHSEHEN: Orts- und UTC-Tag sind in dieser"); console.log(` Zeitzone gerade gleich (${ZONE}, UTC-Stunde ${UTC_STUNDE}).`); console.log(" Die Wahl der Zone stimmt dann nicht mehr — sie steht im"); console.log(" Kopf dieser Datei und gehoert nachgerechnet."); process.exit(3); } ok(true, `Orts- und UTC-Tag unterscheiden sich (${ORTSTAG} gegen ${UTC_TAG})`); /* ---- Der Server, mit derselben verschobenen Uhr ------------------- */ const PORT = await eigenerPort(import.meta, "pruef-tagesgrenze"); const ordner = mkdtempSync(join(tmpdir(), "ws-tagesgrenze-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(120_000, "pruef-tagesgrenze"); await new Promise((r) => setTimeout(r, 700)); const BASIS = `http://127.0.0.1:${PORT}`; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetztIso = new Date().toISOString(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetztIso); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen" + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetztIso); } anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Lena", "modi", "CODE-LENA-0001"); d.close(); async function anmelden(kachel, code) { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: kachel, code }), }); return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); } const dogi = await anmelden("admin", "CODE-DOGI-0001"); const lena = await anmelden("modi", "CODE-LENA-0001"); const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS }); const liste = async (bereich, keks = dogi) => (await (await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } })).json().catch(() => null))?.eintraege || []; console.log("\n=== 1. Ein Eintrag OHNE Datum bekommt den ORTSTAG ==="); /* Die Stelle in workspace-bereiche.js, an der bis zum 01.10. stand: if (!d) aus.datum = jetzt().slice(0, 10); // UTC */ { const a = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, { method: "POST", headers: kopf(lena), body: JSON.stringify({ art: "gemeinsam", titel: "Ohne Datum angelegt" }), }); ok(a.status === 201, `der Wunsch wird angenommen (${a.status})`); const e = (await liste("wunsch", lena)).find((x) => x.titel === "Ohne Datum angelegt"); ok(e?.datum === ORTSTAG, `sein Datum ist der Ortstag ${ORTSTAG}, nicht der UTC-Tag ${UTC_TAG}` + ` (steht: ${e?.datum})`); } console.log("\n=== 2. Und die Kette, an der es aufgefallen ist ==="); /* `daraus` legte den neuen Eintrag mit `jetzt().slice(0, 10)` an. Genau hier stand am 01.10. um 01:22 ein Tag zu frueh — und der Termin landete auf „Was ansteht" im zugeklappten Abschnitt „Vorbei". */ { const w = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, { method: "POST", headers: kopf(lena), body: JSON.stringify({ art: "gemeinsam", titel: "Daraus wird ein Termin", datum: ORTSTAG }), }); const wunschId = (await w.json().catch(() => null))?.id || 0; ok(wunschId > 0, `der Wunsch steht (#${wunschId})`); const a = await fetch(`${BASIS}/workspace/api/bereich/wunsch/${wunschId}/daraus`, { method: "POST", headers: kopf(dogi), body: JSON.stringify({ nach: "ansteht" }), }); const terminId = (await a.json().catch(() => null))?.id || 0; ok(a.status === 201 && terminId > 0, `daraus wird ein Termin (${a.status})`); const t = (await liste("ansteht")).find((x) => x.id === terminId); ok(t?.datum === ORTSTAG, `und er liegt auf dem Ortstag ${ORTSTAG}, nicht auf ${UTC_TAG} (steht: ${t?.datum})`); } console.log("\n=== 3. Und die VERGLEICHE: was ist ueberfaellig? ==="); /* Von den sechzehn Stellen vom 01.10. SCHREIBEN zehn einen Tag; die zwei Abschnitte oben decken sie ab. Sechs VERGLEICHEN gegen „heute" — und davon findet ein Schreibtest keinen einzigen. Die Spalte sieht richtig aus; falsch ist die Zahl, die jemand auf dem Bildschirm liest. Die Regel im Haus ist `frist < heute`. Zwei Aufgaben genuegen, und sie unterscheiden in BEIDEN Zweigen der verschobenen Uhr: Etc/GMT-14 (Ortstag = UTC+1): heute_alt waere gestern -> „gestern" nicht mehr kleiner als heute_alt -> 0 statt 1 Etc/GMT+11 (Ortstag = UTC-1): heute_alt waere morgen -> „heute" kleiner als heute_alt -> 2 statt 1 */ { const GESTERN = tagLokal(-1); const neueAufgabe = (titel, frist) => fetch(`${BASIS}/workspace/api/aufgaben`, { method: "POST", headers: kopf(dogi), body: JSON.stringify({ titel, frist, prioritaet: "mittel" }), }); const a1 = await neueAufgabe("Frist war gestern", GESTERN); const a2 = await neueAufgabe("Frist ist heute", ORTSTAG); ok(a1.status === 201 && a2.status === 201, `zwei Aufgaben angelegt, Fristen ${GESTERN} und ${ORTSTAG}` + ` (${a1.status}/${a2.status})`); const u = await (await fetch(`${BASIS}/workspace/api/uebersicht`, { headers: { cookie: dogi } })).json().catch(() => ({})); ok(u.ueberfaellig === 1, `genau eine ist ueberfaellig — die von gestern (${u.ueberfaellig})`); /* DIESE ZAHL UNTERSCHEIDET NICHT, und das gehoert dazugeschrieben. Gemessen beim Rueckbau: Mit dem UTC-Tag blieb sie 1 — nur zaehlte sie die FALSCHE Aufgabe (die von gestern galt dann als heute faellig). Mit zwei Zahlen allein ist das nicht zu trennen; in beiden Zweigen der verschobenen Uhr bleibt sie 1. Sie steht hier als Begleitprobe: Sie zeigt, dass ueberhaupt gezaehlt wird. Der Unterschied kommt aus der Zeile darueber. Eine Zahl, die aus dem falschen Grund stimmt, soll nicht aussehen wie ein Beweis. */ ok(u.heute === 1, `und eine ist fuer heute faellig (${u.heute}) — Begleitprobe,` + " sie unterscheidet nicht"); /* WAS DER ALTE WEG GESAGT HAETTE. Nicht nachgebaut, sondern ausgerechnet: Damit steht im Lauf, warum diese zwei Zahlen etwas beweisen und nicht nur zufaellig stimmen. */ const alt = UTC_TAG; const waere = [GESTERN, ORTSTAG].filter((f) => f < alt).length; ok(waere !== 1, `mit dem UTC-Tag (${alt}) waeren es ${waere} statt 1 — die Zeile` + " ueber dieser unterscheidet also wirklich"); } console.log("\n=== 4. Gegenprobe: wuerde der alte Weg auffallen? ==="); /* Eine Pruefung, die immer bestaetigt, bestaetigt nichts. Hier wird nachgebaut, was bis zum 01.10. im Code stand — und gezeigt, dass die Behauptungen oben daran scheitern wuerden. */ { const alterWeg = new Date().toISOString().slice(0, 10); ok(alterWeg === UTC_TAG && alterWeg !== ORTSTAG, `der alte Weg (toISOString) liefert ${alterWeg} — das waere der falsche Tag`); ok(heuteLokal() === ORTSTAG, `heuteLokal liefert ${heuteLokal()} — der richtige`); } console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`); console.log(fehler ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);