diff --git a/server/pruef-wecker.mjs b/server/pruef-wecker.mjs
new file mode 100644
index 00000000..52ffd3dd
--- /dev/null
+++ b/server/pruef-wecker.mjs
@@ -0,0 +1,358 @@
+/* DER WECKER — eigene Erinnerungen zu einem Termin.
+
+ Wunsch Filipe (07.09.2026): *"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."*
+
+ ---------------------------------------------------------------------
+ WARUM DAS EINE EIGENE PRUEFUNG BRAUCHT
+
+ Ein Wecker ist die einzige Stelle im Haus, an der die Anwendung von
+ sich aus etwas TUT, ohne dass jemand davorsitzt. Alles andere
+ passiert auf Klick und wird sofort gesehen. Ein Wecker, der nicht
+ klingelt, faellt niemandem auf -- man verpasst den Termin und haelt
+ sich selbst fuer schuld. Und einer, der zu oft klingelt, wird nach
+ drei Tagen stummgeschaltet, womit auch die richtigen Meldungen weg
+ sind.
+
+ DIE VIER FEHLER, die dabei moeglich sind, und alle vier sind hier
+ nachgestellt:
+
+ KLINGELT NICHT die Weckzeit ist erreicht, es kommt nichts
+ KLINGELT DOPPELT dieselbe Erinnerung zweimal
+ KLINGELT NUR EINMAL von drei gesetzten Weckern kommt einer, weil
+ die Doppelsperre den ganzen Termin sperrt statt
+ des einzelnen Abstands
+ KLINGELT ZU SPAET nach einem Ausfall des Dienstes klingeln alle
+ alten Wecker der letzten Wochen nach
+
+ Der letzte ist der teuerste: Er tritt genau dann auf, wenn ohnehin
+ etwas kaputt war, und schuettet den Nutzer mit Meldungen zu Terminen
+ zu, die laengst vorbei sind.
+
+ GEMESSEN WIRD AM ECHTEN SERVER, nicht an der Rechnung im Kopf: Die
+ Wecker werden ueber die Schnittstelle gesetzt, der Weckerlauf wird
+ wirklich aufgerufen, und nachgesehen wird in `push_verschickt` --
+ der Tabelle, die der Melder fuehrt.
+
+ Aufruf: node server/pruef-wecker.mjs */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-wecker-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4357, "die Weckerpruefung");
+
+process.env.PORT = "4357";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+/* KEINE RUHEZEIT WAEHREND DER PRUEFUNG.
+
+ Beim ersten Lauf um 23:42 meldete diese Datei vier Fehler -- und
+ keiner davon war echt: Der Melder schweigt zwischen 22 und 7 Uhr.
+ Sie haette also den Kalender gemessen statt die Software und waere am
+ naechsten Vormittag gruen gewesen. Ein Test, der die Wanduhr als
+ Annahme benutzt, misst irgendwann das Gegenteil.
+
+ Dass die Gegenprobe MITGEFALLEN ist, war dabei die eigentliche
+ Auskunft: Waeren nur die Grenzen falsch gewesen, haette sie gehalten.
+ Sie fiel mit -- also feuerte der Mechanismus ueberhaupt nicht, und
+ die Ursache lag ausserhalb der geprueften Regel.
+
+ `ab 24` und `bis 0` heisst: nie. Die Ruhezeit selbst wird davon nicht
+ ungeprueft -- dafuer gibt es pruef-push. */
+process.env.PUSH_RUHE_AB = "24";
+process.env.PUSH_RUHE_BIS = "0";
+
+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 }); };
+await import("./index.js");
+notbremse(300_000, "pruef-wecker");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = "http://127.0.0.1:4357";
+
+let fehler = 0, gemacht = 0;
+const ok = (b, t) => { gemacht++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetztIso = new Date().toISOString();
+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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetztIso);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
+
+/* EIN ECHTER EMPFAENGER, kein erfundener.
+
+ Erster Anlauf: Adressen auf `example.invalid`, in der Annahme, der
+ Melder trage den Versand nach dem VERSUCH ein. Falsch -- er traegt
+ ihn nach der erfolgreichen ZUSTELLUNG ein (siehe benachrichtige:
+ `if (raus && merkmal)`), und das ist richtig so: Was nicht ankam,
+ darf nicht als geschickt gelten, sonst schweigt der Wecker beim
+ naechsten Lauf endgueltig.
+
+ Meine Annahme war also falsch, nicht der Code. Deshalb steht hier
+ jetzt ein winziger Empfaenger, der wie ein Push-Dienst antwortet
+ (201). Nebennutzen: Damit laeuft in dieser Pruefung auch der echte
+ Versandweg mit -- Verschluesselung, VAPID-Kopf, HTTP -- statt nur
+ der Buchhaltung darum herum. */
+const { createServer } = await import("node:http");
+let angekommen = 0;
+const empfaenger = createServer((req, res) => { angekommen++; res.writeHead(201); res.end(); });
+await new Promise((r) => empfaenger.listen(0, "127.0.0.1", r));
+const pushPort = empfaenger.address().port;
+empfaenger.unref?.();
+
+const geraet = d.prepare(`INSERT INTO push_anmeldungen
+ (person_id, endpunkt, p256dh, auth, erstellt) VALUES (?,?,?,?,?)`);
+/* Gueltige Schluessel, keine Platzhalter: Die Verschluesselung rechnet
+ wirklich damit, und ein Fantasieschluessel liesse sie scheitern --
+ die Pruefung waere rot, ohne dass am Wecker etwas falsch waere. */
+const { createECDH, randomBytes: zufall } = await import("node:crypto");
+const b64u = (b) => Buffer.from(b).toString("base64")
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+for (const pid of [idDogi, idLuna, idNora]) {
+ const kurve = createECDH("prime256v1"); kurve.generateKeys();
+ geraet.run(pid, `http://127.0.0.1:${pushPort}/push/${pid}`,
+ b64u(kurve.getPublicKey()), b64u(zufall(16)), jetztIso);
+}
+
+/* Ortszeit, nie toISOString -- ein Termin "in zwei Stunden" darf nicht
+ nachts auf gestern rutschen. */
+const alsText = (dt) => {
+ const p = (n) => String(n).padStart(2, "0");
+ return `${dt.getFullYear()}-${p(dt.getMonth() + 1)}-${p(dt.getDate())}`
+ + `T${p(dt.getHours())}:${p(dt.getMinutes())}`;
+};
+const inMinuten = (m) => alsText(new Date(Date.now() + m * 60_000));
+
+const terminAnlegen = (titel, beginn, wer) => {
+ d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, erstellt, erstellt_von)
+ VALUES (?, 'call', ?, 30, ?, ?)`).run(titel, beginn, jetztIso, wer);
+ const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+ d.prepare("INSERT INTO termin_teilnehmer (termin_id, person_id) VALUES (?,?)").run(id, wer);
+ return id;
+};
+
+/* Vier Termine, jeder fuer einen der vier Fehler oben. */
+const idBald = terminAnlegen("Call in 90 Minuten", inMinuten(90), idDogi);
+const idFern = terminAnlegen("Call in zehn Tagen", inMinuten(60 * 24 * 10), idDogi);
+const idVorbei = terminAnlegen("Call von gestern", inMinuten(-60 * 26), idDogi);
+const idFremd = terminAnlegen("Noras eigener Call", inMinuten(120), idNora);
+d.close();
+
+async function anmelden(rolle, code) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }) });
+ const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+ if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
+ return keks;
+}
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kLuna = await anmelden("creator", "CODE-CREA-0001");
+
+const ruf = async (art, pfad, rumpf, keks) => {
+ const a = await fetch(BASIS + pfad, {
+ method: art,
+ headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS },
+ body: rumpf === undefined ? undefined : JSON.stringify(rumpf),
+ });
+ let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ }
+ return { status: a.status, daten };
+};
+
+/* =======================================================================
+ 1. Setzen, lesen, abwaehlen
+ ======================================================================= */
+console.log("\n=== Eigene Wecker setzen ===");
+{
+ const leer = await ruf("GET", `/workspace/api/termine/${idBald}/wecker`, undefined, kDogi);
+ ok(leer.status === 200, `die Wecker sind abrufbar (${leer.status})`);
+ ok((leer.daten?.meine || []).length === 0, "am Anfang ist keiner gesetzt");
+ ok((leer.daten?.stufen || []).includes(10080) && (leer.daten?.stufen || []).includes(1440),
+ `die Staffel enthaelt Woche und Tag (${(leer.daten?.stufen || []).join(", ")})`);
+
+ const gesetzt = await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [10080, 1440, 60] }, kDogi);
+ ok(gesetzt.status === 200, `drei Wecker gesetzt (${gesetzt.status})`);
+ ok(JSON.stringify(gesetzt.daten?.meine) === JSON.stringify([10080, 1440, 60]),
+ `und zwar absteigend sortiert (${JSON.stringify(gesetzt.daten?.meine)})`);
+
+ const wieder = await ruf("GET", `/workspace/api/termine/${idBald}/wecker`, undefined, kDogi);
+ ok(JSON.stringify(wieder.daten?.meine) === JSON.stringify([10080, 1440, 60]),
+ "sie stehen beim naechsten Abruf noch da");
+
+ /* ABWAEHLEN MUSS WIRKEN. Wer nur einfuegt statt vorher zu raeumen,
+ sammelt bei jedem Speichern eine Zeile mehr an -- und der
+ abgewaehlte Wecker klingelt trotzdem. */
+ const weniger = await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [1440] }, kDogi);
+ ok(JSON.stringify(weniger.daten?.meine) === JSON.stringify([1440]),
+ `abwaehlen entfernt die anderen wirklich (${JSON.stringify(weniger.daten?.meine)})`);
+ await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [10080, 1440, 60] }, kDogi);
+}
+
+/* =======================================================================
+ 2. Was NICHT geht
+ ======================================================================= */
+console.log("\n=== Die Grenzen ===");
+{
+ const quatsch = await ruf("PUT", `/workspace/api/termine/${idFern}/wecker`,
+ { minuten: [7, 999999, 1440, 1440] }, kDogi);
+ ok(JSON.stringify(quatsch.daten?.meine) === JSON.stringify([1440]),
+ `unbekannte Stufen und Dubletten fallen weg, der Rest bleibt (${JSON.stringify(quatsch.daten?.meine)})`);
+
+ const zuviele = await ruf("PUT", `/workspace/api/termine/${idFern}/wecker`,
+ { minuten: [10080, 4320, 1440, 720, 60, 10] }, kDogi);
+ ok((zuviele.daten?.meine || []).length === 5,
+ `hoechstens fuenf je Termin (${(zuviele.daten?.meine || []).length})`);
+
+ /* Der fremde Termin: Luna sieht Noras Call nicht -- also darf sie
+ sich auch nicht zu ihm wecken lassen. Sonst liesse sich aus dem
+ Meldungstext Titel und Uhrzeit eines fremden Termins lesen. */
+ const fremd = await ruf("PUT", `/workspace/api/termine/${idFremd}/wecker`,
+ { minuten: [60] }, kLuna);
+ ok(fremd.status === 404, `an einem fremden Termin geht es nicht (${fremd.status})`);
+
+ /* GEGENPROBE: An IHREM eigenen ginge es sehr wohl -- sonst bewiese
+ die Zeile darueber nur, dass gar nichts geht. */
+ const eigener = await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [60] }, kDogi);
+ ok(eigener.status === 200, "am eigenen dagegen schon");
+ await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [10080, 1440, 60] }, kDogi);
+}
+
+/* =======================================================================
+ 3. JEDER FUER SICH
+ ======================================================================= */
+console.log("\n=== Jeder stellt seine eigenen ein ===");
+{
+ d2Auf();
+ /* Luna wird Teilnehmerin an DogFathers Termin -- damit sieht sie ihn
+ und kann sich eigene Wecker setzen. Genau der Fall aus Filipes
+ Satz: derselbe Termin, zwei Personen, verschiedene Wecker. */
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare("INSERT OR IGNORE INTO termin_teilnehmer (termin_id, person_id) VALUES (?,?)")
+ .run(idBald, idLuna);
+ dd.close();
+
+ const lunas = await ruf("PUT", `/workspace/api/termine/${idBald}/wecker`,
+ { minuten: [10] }, kLuna);
+ ok(lunas.status === 200 && JSON.stringify(lunas.daten?.meine) === JSON.stringify([10]),
+ `Luna setzt ihren eigenen (${JSON.stringify(lunas.daten?.meine)})`);
+
+ const dogis = await ruf("GET", `/workspace/api/termine/${idBald}/wecker`, undefined, kDogi);
+ ok(JSON.stringify(dogis.daten?.meine) === JSON.stringify([10080, 1440, 60]),
+ `DogFathers bleiben davon unberuehrt (${JSON.stringify(dogis.daten?.meine)})`);
+}
+function d2Auf() { /* nur zur Lesbarkeit oben */ }
+
+/* =======================================================================
+ 4. KLINGELT ES AUCH? Der Weckerlauf am echten Melder
+ ======================================================================= */
+console.log("\n=== Der Lauf ===");
+{
+ const { laufen } = await import("./workspace-push.js");
+ await laufen();
+
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ const merkmale = dd.prepare(
+ "SELECT person_id, merkmal FROM push_verschickt WHERE merkmal LIKE 'wecker:%'").all();
+ dd.close();
+ const alsSatz = merkmale.map((m) => `${m.person_id}/${m.merkmal}`).sort();
+
+ /* Der Termin ist in 90 Minuten. Faellig sind damit die Wecker fuer
+ eine Woche (10080) und einen Tag (1440) -- beide Zeitpunkte liegen
+ in der Vergangenheit. Der Stunden-Wecker (60) noch NICHT: Er
+ klingelt erst in 30 Minuten. */
+ ok(alsSatz.includes(`${idDogi}/wecker:${idBald}:10080`),
+ `der Wochen-Wecker ist raus (${alsSatz.join(" | ") || "nichts"})`);
+ ok(alsSatz.includes(`${idDogi}/wecker:${idBald}:1440`),
+ "der Tages-Wecker ebenfalls -- ZWEI aus einem Termin, nicht einer");
+ ok(!alsSatz.includes(`${idDogi}/wecker:${idBald}:60`),
+ "der Stunden-Wecker noch nicht -- seine Zeit ist noch nicht da");
+ ok(alsSatz.includes(`${idLuna}/wecker:${idBald}:10`) === false,
+ "Lunas Zehn-Minuten-Wecker auch nicht");
+
+ /* KEINE ALTLASTEN. Der Termin von gestern hat einen Wecker, dessen
+ Zeitpunkt laengst vorbei ist -- er darf NICHT nachtraeglich
+ klingeln. Das ist der Fall nach einem Ausfall des Dienstes, und er
+ faellt sonst erst im Ernstfall auf. */
+ const dd2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd2.prepare(`INSERT OR IGNORE INTO termin_wecker (termin_id, person_id, minuten_vorher, gesetzt)
+ VALUES (?,?,?,?)`).run(idVorbei, idDogi, 1440, jetztIso);
+ dd2.close();
+ await laufen();
+ const dd3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const spaet = dd3.prepare(
+ "SELECT 1 FROM push_verschickt WHERE merkmal = ?").get(`wecker:${idVorbei}:1440`);
+ dd3.close();
+ ok(!spaet, "ein Wecker zu einem vergangenen Termin klingelt NICHT nach");
+
+ /* KEINE DOPPELUNG. Ein zweiter Lauf darf nichts Neues erzeugen --
+ sonst klingelt es alle fuenf Minuten erneut, bis der Termin da ist. */
+ const vorher = alsSatz.length;
+ await laufen();
+ const dd4 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const nachher = dd4.prepare(
+ "SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'wecker:%'").get().n;
+ dd4.close();
+ ok(nachher === vorher,
+ `ein zweiter Lauf schickt nichts noch einmal (${vorher} -> ${nachher})`);
+}
+
+/* =======================================================================
+ 5. GEGENPROBE: erkennt diese Pruefung ueberhaupt ein Klingeln?
+ ======================================================================= */
+console.log("\n=== Gegenprobe ===");
+{
+ /* Ohne sie bewiesen alle "klingelt nicht"-Zeilen oben nur, dass der
+ Melder gar nichts tut. Ein Wecker, dessen Zeit JETZT gekommen ist,
+ muss ankommen. */
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare(`INSERT OR IGNORE INTO termin_wecker (termin_id, person_id, minuten_vorher, gesetzt)
+ VALUES (?,?,?,?)`).run(idFremd, idNora, 720, jetztIso);
+ dd.close();
+
+ const { laufen } = await import("./workspace-push.js");
+ await laufen();
+
+ const dd2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const da = dd2.prepare("SELECT 1 FROM push_verschickt WHERE person_id = ? AND merkmal = ?")
+ .get(idNora, `wecker:${idFremd}:720`);
+ dd2.close();
+ ok(!!da, "ein faelliger Wecker kommt wirklich an -- die Messung erkennt beides");
+}
+
+console.log(`\n${gemacht} Pruefungen`);
+console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js
index dffac73a..f7fe5729 100644
--- a/server/workspace-kalender.js
+++ b/server/workspace-kalender.js
@@ -521,6 +521,91 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) =
}
});
+/* =====================================================================
+ DER WECKER -- MEINE EIGENEN, ZU DIESEM TERMIN (07.09.2026)
+
+ Filipe: "das soll man auch selbst jeder fuer sich einstellen
+ koennen."
+
+ Deshalb steht in KEINER dieser beiden Routen eine Rollenfrage: Es
+ geht nie um fremde Wecker. Wer den Termin sehen darf, darf sich zu
+ ihm wecken lassen -- und aendert dabei ausschliesslich seine eigenen
+ Zeilen. Ein Wecker ist keine Eigenschaft des Termins, sondern eine
+ Verabredung mit sich selbst.
+
+ Die Sichtbarkeit wird trotzdem geprueft: Ohne sie koennte man sich
+ ueber eine geratene Nummer wecken lassen und aus dem Meldungstext
+ Titel und Uhrzeit eines fremden Termins lesen.
+ ===================================================================== */
+
+/** Erlaubte Vorlaufzeiten in Minuten. Eine feste Liste, keine freie
+ * Zahl: Ein Wecker "in 7 Minuten" waere im Fuenf-Minuten-Takt des
+ * Melders ohnehin ungenau, und eine offene Zahl ist eine Eingabe, die
+ * jemand pruefen muss. Die Staffel folgt der Recherche (Google,
+ * Outlook, Morgen): eine Woche, drei Tage, ein Tag, der Morgen des
+ * Tages, eine Stunde, zehn Minuten. */
+const WECKER_STUFEN = [10080, 4320, 1440, 720, 60, 10];
+const WECKER_MAX = 5; /* wie bei Google -- mehr weckt niemanden besser */
+
+kalenderRouter.get("/workspace/api/termine/:id/wecker", (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+ const { wo, werte } = sichtbar(req.person);
+ const da = db().prepare(`SELECT t.id ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id);
+ if (!da) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ res.json({
+ stufen: WECKER_STUFEN,
+ hoechstens: WECKER_MAX,
+ meine: db().prepare(
+ "SELECT minuten_vorher FROM termin_wecker WHERE termin_id = ? AND person_id = ? ORDER BY minuten_vorher DESC")
+ .all(id, req.person.id).map((z) => z.minuten_vorher),
+ });
+ } catch (fehler) {
+ console.error("[workspace] Wecker lesen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+kalenderRouter.put("/workspace/api/termine/:id/wecker", gleicheHerkunft, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+ const { wo, werte } = sichtbar(req.person);
+ const da = db().prepare(`SELECT t.id ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id);
+ if (!da) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* Nur bekannte Stufen, ohne Dubletten, hoechstens fuenf. Gefiltert
+ statt abgelehnt: Eine Oberflaeche, die eine unbekannte Zahl
+ schickt, hat einen Fehler -- aber die vier richtigen daneben
+ sollen deshalb nicht verlorengehen. */
+ const roh = Array.isArray(req.body?.minuten) ? req.body.minuten : [];
+ const gewaehlt = [...new Set(roh.map(Number).filter((m) => WECKER_STUFEN.includes(m)))]
+ .sort((a, b) => b - a).slice(0, WECKER_MAX);
+
+ const d = db();
+ d.exec("BEGIN");
+ try {
+ /* Erst raeumen, dann setzen -- das ist der einzige Weg, bei dem
+ ein ABGEWAEHLTER Wecker auch wirklich verschwindet. Wer nur
+ einfuegt, sammelt bei jedem Speichern eine Zeile mehr an. */
+ d.prepare("DELETE FROM termin_wecker WHERE termin_id = ? AND person_id = ?")
+ .run(id, req.person.id);
+ const rein = d.prepare(
+ "INSERT INTO termin_wecker (termin_id, person_id, minuten_vorher, gesetzt) VALUES (?,?,?,?)");
+ const jetzt = new Date().toISOString();
+ for (const m of gewaehlt) rein.run(id, req.person.id, m, jetzt);
+ d.exec("COMMIT");
+ } catch (f) { d.exec("ROLLBACK"); throw f; }
+
+ res.json({ meine: gewaehlt });
+ } catch (fehler) {
+ console.error("[workspace] Wecker setzen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
diff --git a/server/workspace-push.js b/server/workspace-push.js
index a5c70756..e5d5dc90 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -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
diff --git a/server/workspace.js b/server/workspace.js
index 3003f0a6..ac22c006 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -1181,6 +1181,49 @@ export function db() {
);
CREATE INDEX IF NOT EXISTS idx_termin_teilnehmer ON termin_teilnehmer (person_id);
+ /* =================================================================
+ DER WECKER (07.09.2026)
+
+ Wunsch 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 jeder fuer sich selbst einstellen koennen."
+
+ EINE ZEILE IST EIN WECKER. Nicht ein Feld am Termin mit einer
+ Liste darin: Mehrere Vorlaufzeiten je Termin sind der ganze
+ Zweck, und "jeder fuer sich" heisst, dass zwei Personen am
+ SELBEN Termin verschiedene Wecker haben. Beides zusammen ist
+ eine klassische n:m-Beziehung, und die gehoert in eine eigene
+ Tabelle. Ein Feld mit kommagetrennten Zahlen waere beim ersten
+ "zeig mir alle faelligen Wecker" nicht mehr abfragbar.
+
+ Die Spalte minuten_vorher statt eines Zeitpunkts: 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, egal wie oft der
+ Termin wandert.
+
+ ON DELETE CASCADE an beiden Seiten: Ein Wecker ohne Termin
+ weckt niemanden, und ein Wecker ohne Person auch nicht.
+ Zusammengesetzter Primaerschluessel: Dieselbe Person kann
+ denselben Abstand nicht zweimal setzen -- sonst klingelt es
+ doppelt, und das merkt man erst nachts.
+
+ Recherche (Google Calendar, Outlook, Morgen): Fuenf Wecker je
+ Termin sind das uebliche Maximum, und die verbreitete Empfehlung
+ fuer Wichtiges lautet "eine Woche, ein Tag, am Tag selbst".
+ Genau diese Staffel bietet die Oberflaeche an. */
+ CREATE TABLE IF NOT EXISTS termin_wecker (
+ termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ minuten_vorher INTEGER NOT NULL,
+ gesetzt TEXT NOT NULL,
+ PRIMARY KEY (termin_id, person_id, minuten_vorher)
+ );
+ CREATE INDEX IF NOT EXISTS idx_termin_wecker_person
+ ON termin_wecker (person_id);
+
/* Dasselbe fuer die Wiederholungen. Eine Serie ist eine Regel --
die Teilnehmer gehoeren zur Regel, nicht zur einzelnen
Auspraegung, sonst muesste man sie jede Woche neu eintragen.
diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css
index c88db080..c7126326 100644
--- a/workspace/assets/css/kalender.css
+++ b/workspace/assets/css/kalender.css
@@ -938,3 +938,66 @@
@media (prefers-reduced-motion: reduce) {
.k-abo__kopf, .k-abo__kopf::after { transition: none; }
}
+
+/* =====================================================================
+ DER WECKER (07.09.2026)
+
+ Filipe: "wie so ein wecker, den man in den eintraegen aktivieren oder
+ ausschalten kann ... auch mehrmals informiert."
+
+ DER KNOPF ZEIGT SEINEN ZUSTAND, ohne dass man ihn anfassen muss:
+ grau und leer heisst "keine Erinnerung", golden mit einer Zahl heisst
+ "so viele". Das beantwortet die haeufigste Frage von aussen. Gold
+ und nicht Rot: Ein Wecker ist kein Alarm, sondern ein Dienst.
+ ===================================================================== */
+.wecker-knopf {
+ --w-ton: #e3b341;
+ display: inline-flex; align-items: center; gap: 4px;
+ min-height: 30px; padding: 3px 9px 3px 7px;
+ border: 1px solid var(--rand); border-radius: 999px;
+ background: transparent; color: var(--text-still);
+ font: inherit; font-size: .78rem; cursor: pointer;
+ transition: color var(--tempo), border-color var(--tempo), background var(--tempo);
+}
+.wecker-knopf:hover { color: var(--text); border-color: var(--rand-hell); }
+.wecker-knopf__svg {
+ width: 16px; height: 16px; flex: none;
+ fill: none; stroke: currentColor; stroke-width: 1.6;
+ stroke-linecap: round; stroke-linejoin: round;
+}
+.wecker-knopf__svg .fuell { stroke: none; fill: transparent; }
+.wecker-knopf__zahl { font-weight: 700; font-variant-numeric: tabular-nums; }
+
+/* Gesetzt: der Wecker bekommt Farbe und Fuellung -- dieselbe Sprache
+ wie die Rollensymbole, wo die Flaeche den Zustand traegt. */
+.wecker-knopf[data-an="ja"] {
+ color: var(--w-ton);
+ border-color: color-mix(in srgb, var(--w-ton) 45%, transparent);
+ background: color-mix(in srgb, var(--w-ton) 12%, transparent);
+}
+.wecker-knopf[data-an="ja"] .wecker-knopf__svg .fuell {
+ fill: color-mix(in srgb, var(--w-ton) 42%, transparent);
+}
+
+.wecker-dialog { max-width: 420px; }
+.wecker-hinweis { margin: 0 0 12px; }
+.wecker-liste { display: grid; gap: 2px; }
+.wecker-zeile {
+ display: flex; align-items: center; gap: 11px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: background var(--tempo);
+}
+.wecker-zeile:hover { background: rgba(255, 255, 255, .05); }
+/* Das Kaestchen gross genug fuer den Daumen -- eine Auswahl, die man
+ dreimal antippen muss, ist keine. */
+.wecker-zeile input { width: 20px; height: 20px; flex: none; accent-color: #e3b341; }
+.wecker-zeile__text { font-size: .92rem; }
+
+@media (prefers-reduced-motion: reduce) {
+ .wecker-knopf, .wecker-zeile { transition: none; }
+}
+@media (forced-colors: active) {
+ .wecker-knopf[data-an="ja"] { border: 2px solid CanvasText; }
+}
diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js
index 7db46e3f..b4cc4bb3 100644
--- a/workspace/assets/js/kalender.js
+++ b/workspace/assets/js/kalender.js
@@ -935,6 +935,11 @@
a.href = 'aufgaben.html';
rechts.append(a);
} else {
+ /* DER WECKER (07.09.2026). Er steht bei JEDEM Termin, den man
+ sieht -- auch bei fremden: Ein Wecker ist eine Verabredung mit
+ sich selbst, keine Aenderung am Termin. Deshalb steht er auch
+ VOR "erledigt": Er ist das, was man am haeufigsten will. */
+ rechts.append(weckerKnopf(e));
rechts.append(tuKnopf(e.erledigt ? 'wieder offen' : 'erledigt', async () => {
const a = await hole('/workspace/api/termine/' + e.id, {
method: 'PATCH',
@@ -1191,6 +1196,12 @@
}
$('tagdialog-zu').addEventListener('click', () => $('tagdialog').close());
+ $('wecker-zu')?.addEventListener('click', () => $('wecker-dialog').close());
+ /* Klick auf den Schleier schliesst -- wie beim Tagesfenster. Zwei
+ Dialoge, die sich verschieden bedienen lassen, sind einer zu viel. */
+ $('wecker-dialog')?.addEventListener('click', (ev) => {
+ if (ev.target === $('wecker-dialog')) $('wecker-dialog').close();
+ });
/* Klick auf den Rand schließt -- erwartet man bei einem Dialog. */
$('tagdialog').addEventListener('mousedown', (ev) => {
if (ev.target === $('tagdialog')) $('tagdialog').close();
@@ -1224,6 +1235,133 @@
* eine Absage bringt; die Sperre steht im Server. */
const darfAendern = (e) => LEITUNG.has(ich.rolle) || e.erstellt_von === ich.id;
+ /* =====================================================================
+ DER WECKER -- BEDIENUNG (07.09.2026)
+
+ Wunsch Filipe: "wie so ein wecker, den man in den eintraegen
+ aktivieren oder ausschalten kann ... auch mehrmals informiert,
+ einmal eine woche vorher, einmal drei tage vorher und einmal am tag
+ selber. das soll jeder fuer sich selbst einstellen koennen."
+
+ EIN KNOPF, DER SEINEN ZUSTAND ZEIGT. Keine Glocke, die man erst
+ anklicken muss, um zu erfahren, ob sie an ist: Steht eine Zahl
+ daran, sind so viele Wecker gesetzt. Das beantwortet die haeufigste
+ Frage ("bekomme ich hier eine Erinnerung?") ohne einen Klick.
+
+ Die Auswahl selbst ist eine Liste zum Ankreuzen, kein Menue: Es
+ sind sechs Moeglichkeiten, mehrere davon gleichzeitig, und beides
+ kann ein Auswahlmenue nicht.
+
+ Gespeichert wird SOFORT beim Ankreuzen. Ein "Speichern"-Knopf
+ daneben waere ein zweiter Schritt fuer eine Entscheidung, die aus
+ einem Klick besteht -- und der haeufigste Weg, dass eine
+ Einstellung doch nicht ankommt. */
+ const WECKER_NAME = {
+ 10080: 'Eine Woche vorher',
+ 4320: 'Drei Tage vorher',
+ 1440: 'Einen Tag vorher',
+ 720: 'Am selben Tag (12 Std.)',
+ 60: 'Eine Stunde vorher',
+ 10: 'Zehn Minuten vorher',
+ };
+
+ function weckerKnopf(e) {
+ const knopf = el('button', 'wecker-knopf');
+ knopf.type = 'button';
+ knopf.title = 'Erinnerungen für diesen Termin';
+ const zahl = el('span', 'wecker-knopf__zahl');
+ knopf.append(weckerZeichen(), zahl);
+
+ const stand = (anzahl) => {
+ knopf.dataset.an = anzahl > 0 ? 'ja' : 'nein';
+ zahl.textContent = anzahl > 0 ? String(anzahl) : '';
+ knopf.setAttribute('aria-label',
+ anzahl > 0 ? `${anzahl} Erinnerung(en) gesetzt` : 'Keine Erinnerung gesetzt');
+ };
+ stand(0);
+
+ /* Der Stand wird beim Aufbau geholt, nicht erst beim Klick: Sonst
+ saehe man erst nach dem Oeffnen, ob ueberhaupt etwas gesetzt ist
+ -- und das ist die Frage, die man von aussen beantwortet haben
+ will. */
+ (async () => {
+ try {
+ const a = await hole(`/workspace/api/termine/${e.id}/wecker`);
+ if (a.ok) stand(((await a.json()).meine || []).length);
+ } catch { /* ohne Stand bleibt der Knopf leise */ }
+ })();
+
+ knopf.addEventListener('click', () => weckerOeffnen(e, stand));
+ return knopf;
+ }
+
+ function weckerZeichen() {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('class', 'wecker-knopf__svg');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.innerHTML = '