Der Wecker: mehrere Erinnerungen je Termin, jeder fuer sich

Filipe: "wie so ein wecker, den man auch in den eintraegen aktivieren
oder ausschalten kann, den soll man sogar so einstellen koennen, dass
er einen auch mehrmals informiert, einmal eine woche vorher, einmal
drei tage vorher und einmal am tag selber. das soll man auch selbst
jeder fuer sich einstellen koennen. hol die besten skills."

NACHGELESEN, NICHT GERATEN. Google Calendar erlaubt fuenf Erinnerungen
je Termin, Outlook genau eine, Apple zwei. Die verbreitete Empfehlung
fuer Wichtiges lautet "eine Woche, ein Tag, am Tag selbst" -- also
genau die Staffel, die Filipe genannt hat. Uebernommen: sechs Stufen
zur Wahl (Woche, drei Tage, ein Tag, selber Tag, Stunde, zehn Minuten),
hoechstens fuenf gleichzeitig.

EINE ZEILE IST EIN WECKER -- kein Feld am Termin mit einer Liste darin.
Mehrere Vorlaufzeiten UND "jeder fuer sich" sind zusammen eine
n:m-Beziehung; ein Feld mit kommagetrennten Zahlen waere beim ersten
"zeig mir alle faelligen Wecker" nicht mehr abfragbar.

DER ABSTAND STEHT IN DER DATENBANK, NICHT DER ZEITPUNKT. Ein Zeitpunkt
muesste bei jeder Terminverschiebung nachgezogen werden -- und genau
das vergisst man. Ein Abstand rechnet sich beim Wecken aus dem
aktuellen Beginn und ist damit immer richtig.

ZWEI GRENZEN IM WECKLAUF, und beide sind noetig: faellig (Weckzeit
erreicht) UND der Termin liegt noch vor uns. Ohne die zweite wuerde
beim ersten Lauf nach einem Ausfall jeder alte Wecker der letzten
Wochen nachtraeglich klingeln.

DER ABSTAND GEHOERT INS MERKMAL der Doppelsperre. Ohne ihn wuerde der
erste Wecker eines Termins alle weiteren sperren -- und genau das
Mehrfach-Wecken, um das es geht, faende nie statt.

DIE PRUEFUNG HAT SICH ZWEIMAL SELBST KORRIGIERT

  1. Erster Lauf um 23:42: vier Fehler, keiner echt -- der Melder
     schweigt zwischen 22 und 7 Uhr. Sie hat den Kalender gemessen,
     nicht die Software, und waere am Vormittag gruen gewesen. Dass die
     GEGENPROBE mitgefallen ist, war die eigentliche Auskunft: Waeren
     nur die Grenzen falsch, haette sie gehalten. Die Ruhezeit ist
     jetzt ueber die Umgebung einstellbar (Vorgabe unveraendert 22/7),
     damit eine Pruefung ihre Voraussetzung herstellen kann.
  2. Danach immer noch nichts: Ich hatte angenommen, der Melder trage
     den Versand nach dem VERSUCH ein. Er traegt ihn nach der
     erfolgreichen ZUSTELLUNG ein -- und das ist richtig so. Meine
     Annahme war falsch, nicht der Code. Die Pruefung hat jetzt einen
     winzigen echten Empfaenger; damit laeuft der ganze Versandweg mit,
     Verschluesselung und VAPID inbegriffen.

pruef-wecker.mjs: 20 Pruefungen. Sie stellt alle vier Fehler nach, die
bei einem Wecker moeglich sind (klingelt nicht / doppelt / nur einmal
von dreien / nachtraeglich nach einem Ausfall) -- plus die Gegenprobe,
dass ein faelliger Wecker wirklich ankommt.

Zwoelf weitere Pruefungen gelaufen, alle gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-07 23:51:20 +02:00
co-authored by Claude Opus 5
parent 63036fc33e
commit 1c2e196d1d
25 changed files with 1008 additions and 193 deletions
+100 -2
View File
@@ -72,6 +72,12 @@ export const ARTEN = [
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true },
{ schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true },
{ schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true },
/* DER WECKER (07.09.2026). Bewusst eine EIGENE Art neben
"termin_gleich": Die eine ist eine Vorgabe des Hauses fuer jeden
Termin, der andere ist eine Entscheidung DIESER Person fuer DIESEN
Termin. Wer die feste Stunden-Erinnerung abschaltet, will damit
nicht seine selbst gesetzten Wecker verlieren. */
{ schluessel: "termin_wecker", name: "Selbst gesetzter Wecker", vorgabe: true },
{ schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true },
{ schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
/* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art,
@@ -82,8 +88,44 @@ export const ARTEN = [
];
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
/* Ruhezeit. Nichts zwischen 22 und 7 Uhr. */
const RUHE_AB = 22, RUHE_BIS = 7;
/** Wie ein Vorlauf heisst, wenn er im Meldungskopf steht.
*
* In Minuten gerechnet, in Worten gemeldet: "In einer Woche" liest
* sich beim Aufwachen besser als "10080 Minuten vorher". Die Staffel
* ist dieselbe, die die Oberflaeche anbietet -- kaeme hier eine
* ungerade Zahl an, faellt sie auf den allgemeinen Satz zurueck statt
* auf einen falschen. */
export function vorlauf(minuten) {
const m = Number(minuten) || 0;
if (m >= 10080) return "Erinnerung: in einer Woche";
if (m >= 4320) return "Erinnerung: in drei Tagen";
if (m >= 1440) return "Erinnerung: morgen";
if (m >= 720) return "Erinnerung: heute";
if (m >= 120) return `Erinnerung: in ${Math.round(m / 60)} Stunden`;
if (m >= 60) return "Erinnerung: in einer Stunde";
if (m > 0) return `Erinnerung: in ${m} Minuten`;
return "Erinnerung: jetzt";
}
/* RUHEZEIT. Nichts zwischen 22 und 7 Uhr.
DIE GRENZEN SIND SEIT DEM 07.09.2026 EINSTELLBAR, und der Grund ist
eine Pruefung, die sich selbst ins Knie geschossen hat: Die
Weckerpruefung lief um 23:42 und meldete vier Fehler -- der Melder
schwieg, weil Ruhezeit war. Sie hat also den Kalender gemessen, nicht
die Software, und waere um 10 Uhr gruen gewesen. Genau die Zeitbombe,
die in der Projektnotiz steht.
Eine Pruefung darf ihre Voraussetzung herstellen duerfen. Deshalb
liest die Ruhezeit jetzt aus der Umgebung -- Vorgabe unveraendert
22 bis 7, und im Betrieb setzt niemand etwas anderes. Die Alternative
waere gewesen, in der Pruefung eine Zeit zu faelschen; das faelscht
dann irgendwann auch etwas anderes mit.
Nebennutzen: Wenn das Team eines Tages andere Zeiten will, ist es
eine Umgebungsvariable und kein Programmierauftrag. */
const RUHE_AB = Number(process.env.PUSH_RUHE_AB ?? 22);
const RUHE_BIS = Number(process.env.PUSH_RUHE_BIS ?? 7);
export function istRuhezeit(d = new Date()) {
const s = d.getHours();
return s >= RUHE_AB || s < RUHE_BIS;
@@ -252,6 +294,62 @@ export async function laufen() {
}
}
/* --- DIE SELBST GESETZTEN WECKER ----------------------------------
Ein Wecker steht als ABSTAND in der Datenbank (minuten_vorher),
nicht als Zeitpunkt. Die Weckzeit wird deshalb hier gerechnet --
und ist damit auch nach einer Terminverschiebung richtig, ohne
dass irgendjemand etwas nachziehen muss.
ZWEI GRENZEN, und beide sind noetig:
faellig die Weckzeit ist erreicht (beginn - abstand <= jetzt)
nicht zu spaet der Termin liegt noch VOR uns
Ohne die zweite wuerde beim ersten Lauf nach einer Auszeit des
Dienstes jeder alte Wecker der letzten Wochen nachtraeglich
klingeln -- ein Schwall Meldungen zu Terminen, die laengst vorbei
sind. Das ist der Fall, den man beim Bauen nie sieht und der beim
ersten Neustart nach einem Ausfall sofort auffaellt.
DIE RUHEZEIT VERSCHLUCKT NICHTS. `benachrichtige` merkt sich erst
nach dem Verschicken, dass etwas raus ist -- was zwischen 22 und
7 Uhr faellig wird, geht also am Morgen raus, sobald der Termin
noch bevorsteht. Ein Wecker, der nachts still ist und dafuer
morgens klingelt, ist genau richtig; einer, der verschluckt wird,
waere schlimmer als keiner.
Rechnen auf Textebene geht hier NICHT (Abstand in Minuten), also
mit Date -- aber ausschliesslich lokal, nie ueber toISOString. */
const alsText = (dt) => {
const p2 = (n) => String(n).padStart(2, "0");
return `${dt.getFullYear()}-${p2(dt.getMonth() + 1)}-${p2(dt.getDate())}`
+ `T${p2(dt.getHours())}:${p2(dt.getMinutes())}`;
};
for (const w of d.prepare(`
SELECT w.termin_id, w.person_id, w.minuten_vorher,
t.titel, t.beginn, t.art
FROM termin_wecker w
JOIN termine t ON t.id = w.termin_id
WHERE t.erledigt = 0 AND t.beginn > ?`).all(jetztText)) {
const beginn = new Date(String(w.beginn).replace(" ", "T"));
if (Number.isNaN(beginn.getTime())) continue;
const weckzeit = new Date(beginn.getTime() - w.minuten_vorher * 60_000);
if (alsText(weckzeit) > jetztText) continue; // noch nicht so weit
await senden(w.person_id, "termin_wecker", {
titel: vorlauf(w.minuten_vorher),
text: `${w.titel} · ${String(w.beginn).slice(8, 10)}.${String(w.beginn).slice(5, 7)}. `
+ `um ${String(w.beginn).slice(11, 16)} Uhr`,
ziel: w.art === "call" ? "/workspace/calls.html" : "/workspace/kalender.html",
/* Der Abstand gehoert ins Merkmal. Ohne ihn wuerde der erste
Wecker eines Termins alle weiteren als "schon geschickt"
sperren -- und genau das Mehrfach-Wecken, um das es geht,
faende nie statt. */
merkmal: `wecker:${w.termin_id}:${w.minuten_vorher}`,
});
}
/* --- Gespraeche ohne Protokoll ------------------------------------ */
for (const t of d.prepare(`
SELECT t.id, t.titel, t.erstellt_von FROM termine t