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 = '' + + '' + + '' + + ''; + return svg; + } + + async function weckerOeffnen(e, stand) { + const dlg = $('wecker-dialog'); + if (!dlg) return; + $('wecker-titel').textContent = e.titel; + $('wecker-wann').textContent = `${tagLang(e.tag)}${e.uhr ? ' · ' + e.uhr + ' Uhr' : ''}`; + const liste = $('wecker-liste'); + liste.textContent = 'Wird geladen …'; + dlg.showModal(); + + let daten; + try { + const a = await hole(`/workspace/api/termine/${e.id}/wecker`); + if (!a.ok) throw new Error('nicht ok'); + daten = await a.json(); + } catch { + liste.textContent = 'Die Erinnerungen konnten nicht geladen werden.'; + return; + } + + const gewaehlt = new Set(daten.meine || []); + liste.textContent = ''; + for (const m of daten.stufen || []) { + const zeile = el('label', 'wecker-zeile'); + const kasten = document.createElement('input'); + kasten.type = 'checkbox'; + kasten.checked = gewaehlt.has(m); + kasten.addEventListener('change', async () => { + if (kasten.checked) gewaehlt.add(m); else gewaehlt.delete(m); + /* Die Obergrenze steht im Server; hier wird sie nur gezeigt, + damit niemand ankreuzt, was gleich wieder verschwindet. */ + if (gewaehlt.size > (daten.hoechstens || 5)) { + gewaehlt.delete(m); kasten.checked = false; + $('wecker-fehler').textContent = + `Mehr als ${daten.hoechstens} Erinnerungen je Termin gehen nicht.`; + return; + } + $('wecker-fehler').textContent = ''; + try { + const a = await hole(`/workspace/api/termine/${e.id}/wecker`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ minuten: [...gewaehlt] }), + }); + if (!a.ok) throw new Error('nicht ok'); + stand(gewaehlt.size); + } catch { + $('wecker-fehler').textContent = 'Speichern ging nicht.'; + } + }); + zeile.append(kasten, el('span', 'wecker-zeile__text', WECKER_NAME[m] || `${m} Minuten vorher`)); + liste.append(zeile); + } + } + function formularOeffnen(tag, termin) { /* Vorbelegung: der angeklickte Tag um 18:00 -- ein realistischer Zeitpunkt für einen Call, spart in der Regel zwei Klicks. Ohne Tag diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 7113543e..639ce41d 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -212,10 +212,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 6a5d282c..af8a242e 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -135,10 +135,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 63720f16..8f7edb7a 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -144,14 +144,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 8308cc3d..512df23d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -77,11 +77,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 2db312ca..0ca259a8 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -9,17 +9,17 @@ - - + + - - - + + + @@ -155,10 +155,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index d10e72f1..9f883476 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -167,12 +167,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 7a8368dc..0b3934f1 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -96,10 +96,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 097cd470..18481ce9 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -219,6 +219,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 19b121bc..f9bb9014 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -256,13 +256,43 @@ + + +
+
+

Erinnerung

+

+
+ +
+

+ Nur für dich. Andere Teilnehmer stellen ihre Erinnerungen selbst ein. +

+
+ +
+ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index f05b5210..ddc42cc5 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -212,10 +212,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index a8925a38..e6e47964 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -157,10 +157,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index ef5597ab..a524d501 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -202,11 +202,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index d822b4af..48398971 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -94,11 +94,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index acb154ad..a286a53a 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -127,10 +127,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 0c759005..d8c98b27 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -9,12 +9,12 @@ - - + + - - + + @@ -320,10 +320,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index e9951d84..cbdcfbce 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -73,11 +73,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 177dd6cc..ca35e259 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -114,10 +114,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 423ccb17..d7a516db 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -9,13 +9,13 @@ - - + + - - - + + + @@ -137,11 +137,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index e70f72c8..0d2bf257 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -9,11 +9,11 @@ - - - - - + + + + + @@ -178,10 +178,10 @@ - - - - - + + + + +