diff --git a/server/index.js b/server/index.js index 080f1e4a..950cb5f7 100644 --- a/server/index.js +++ b/server/index.js @@ -41,6 +41,7 @@ import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; import { leistungRouter } from "./workspace-leistung.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; +import { icsRouter } from "./workspace-ics.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -186,6 +187,20 @@ app.use(chatRouter); melden, deshalb stehen sie beieinander. */ app.use(leistungRouter); app.use(fruehwarnungRouter); +/* KALENDER ZUM ABONNIEREN (06.09.2026, Stufe 3.2 des Plans). + + Steht VOR aufgabenRouter, und das ist keine Stilfrage: Der haengt + eine Schranke ueber ALLE Pfade unter /workspace/api. Der ICS-Weg + selbst liegt zwar unter /workspace/kalender/, aber die Verwaltung + des eigenen Links unter /workspace/api/ics -- und die bringt ihre + eigene Schranke mit. + + Der Kalender-Weg selbst MUSS ohne Anmeldung erreichbar sein: Google, + Apple und Outlook holen ihn im Hintergrund ab, ohne Browser und ohne + Sitzung. Ein Kalender, der eine Anmeldung verlangt, laesst sich + schlicht nicht abonnieren. Der geheime Schluessel in der Adresse IST + deshalb der Zugang -- 32 Byte Zufall, jederzeit widerrufbar. */ +app.use(icsRouter); app.use(vorlagenRouter); app.use(schulungRouter); app.use(ampelRouter); diff --git a/server/pruef-alle-wege.mjs b/server/pruef-alle-wege.mjs index 65ecfb68..63411e66 100644 --- a/server/pruef-alle-wege.mjs +++ b/server/pruef-alle-wege.mjs @@ -413,6 +413,64 @@ ok(ohneSchranke.length === 0, ? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ") : "kein schreibender Weg nimmt eine fremde Herkunft an"); +/* ======================================================================= + 4b. Wege AUSSERHALB von /workspace/api — die stille Luecke + ======================================================================= + + Die Sammlung oben liest nur Routen unter /workspace/api. Das war + jahrelang vollstaendig, weil es nichts anderes gab. Seit dem + 06.09.2026 gibt es den abonnierbaren Kalender unter + /workspace/kalender/.ics -- und der MUSS ohne Anmeldung + gehen, sonst kann ihn kein Kalenderprogramm abholen. + + Genau solche Wege sind die gefaehrlichen: Sie tauchen in keiner + Sammlung auf, also faellt auch nicht auf, wenn der naechste ohne + Schranke dazukommt. Deshalb werden sie hier eigens gesucht -- und + jeder muss unten mit BEGRUENDUNG stehen. Wer einen neuen anlegt und + ihn nicht eintraegt, bekommt hier einen Fehler statt eines stillen + Lochs. */ +console.log("\n=== Wege ausserhalb von /workspace/api ==="); +{ + const ANDERE = new Map([ + ["/workspace/kalender/:datei", + "der abonnierbare Kalender (ICS). Muss ohne Anmeldung gehen -- Google, " + + "Apple und Outlook holen ihn ohne Browser und ohne Sitzung ab. Der " + + "geheime Schluessel in der Adresse ist der Zugang; geprueft wird das " + + "in pruef-ics.mjs (Sichtbarkeit, Widerruf, falsche Schluessel)."], + ]); + + const gefunden = []; + for (const datei of readdirSync(HIER).filter((n) => /^workspace.*\.js$/.test(n))) { + const text = readFileSync(join(HIER, datei), "utf8"); + for (const t of text.matchAll( + /Router\.(get|post|patch|put|delete)\(\s*"(\/workspace\/(?!api\/)[^"]*)"/g)) { + gefunden.push({ art: t[1].toUpperCase(), pfad: t[2], datei }); + } + } + + const unbekannt = gefunden.filter((r) => !ANDERE.has(r.pfad)); + ok(unbekannt.length === 0, + unbekannt.length + ? "NICHT BEGRUENDET -- diese Wege liegen ausserhalb von /workspace/api und " + + "stehen in keiner Liste:\n " + + unbekannt.map((r) => `${r.art} ${r.pfad} [${r.datei}]`).join("\n ") + + "\n Jeder von ihnen umgeht die Schranke oben. Eintragen und begruenden." + : `alle ${gefunden.length} Wege ausserhalb von /workspace/api sind begruendet`); + + /* Und die Begruendungen duerfen nicht verwaisen: Ein Eintrag fuer + einen Weg, den es nicht mehr gibt, taeuscht Abdeckung vor. */ + const veraltet = [...ANDERE.keys()].filter((p) => !gefunden.some((r) => r.pfad === p)); + ok(veraltet.length === 0, + veraltet.length ? `begruendet, aber nicht mehr vorhanden: ${veraltet.join(", ")}` + : `keine verwaiste Begruendung`); + + /* Der Kalenderweg muss OHNE gueltigen Schluessel dichtmachen -- die + ausfuehrliche Pruefung steht in pruef-ics, hier nur der Riegel. */ + const ohne = await ruf("GET", "/workspace/kalender/beliebig.ics", null); + ok(ohne.status === 404 && !String(ohne.text).includes("BEGIN:VEVENT"), + `ein erfundener Kalenderschluessel liefert nichts (HTTP ${ohne.status})`); +} + /* ======================================================================= 5. Hat irgendein Weg gar nicht geantwortet? ======================================================================= */ diff --git a/server/pruef-breiten.mjs b/server/pruef-breiten.mjs index 66426ce7..74be448b 100644 --- a/server/pruef-breiten.mjs +++ b/server/pruef-breiten.mjs @@ -180,6 +180,15 @@ const messen = () => { const sichtbar = (e) => { const r = e.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; + /* NICHT ZAEHLEN, WAS IN EINEM ZUGEKLAPPTEN
STECKT. + Chromium versteckt dessen Inhalt ueber `content-visibility`, nicht + ueber `display` -- die Kaesten behalten deshalb eine Groesse und + sehen fuer jede Messung aus wie sichtbare Bedienelemente. Am + 06.09.2026 meldete das einen "verdeckten" Knopf im + Kalender-Abo-Kasten, den auf dem Bildschirm niemand sehen konnte. + Eine Warnung ueber etwas Unsichtbares gewoehnt man sich ab zu + lesen. */ + if (e.closest("details:not([open])")) return false; const st = getComputedStyle(e); return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05; }; diff --git a/server/pruef-handy.mjs b/server/pruef-handy.mjs index 336e8562..54faa231 100644 --- a/server/pruef-handy.mjs +++ b/server/pruef-handy.mjs @@ -241,12 +241,70 @@ const messen = () => ({ if (r.top < 0 || r.bottom > window.innerHeight) return false; const st = getComputedStyle(e); if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false; + /* NICHT ZAEHLEN, WAS IN EINEM ZUGEKLAPPTEN
STECKT. + Chromium versteckt dessen Inhalt ueber `content-visibility`, + nicht ueber `display` -- die Kaesten behalten deshalb eine + Groesse und sehen fuer jede Messung aus wie sichtbare + Bedienelemente. Am 06.09.2026 meldete das einen "verdeckten" + Knopf, den auf dem Bildschirm niemand sehen konnte. Eine + Warnung ueber etwas Unsichtbares gewoehnt man sich ab zu + lesen. */ + if (e.closest("details:not([open])")) return false; const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return oben && oben !== e && !e.contains(oben) && !oben.contains(e); }) - .map((e) => `${e.tagName}"${(e.textContent || "").trim().slice(0, 16)}"`) + /* WER verdeckt WEN, und wo. Vorher stand hier nur der Name des + verdeckten Knopfes -- eine Meldung wie `BUTTON"Meine Sicht"` sagt, + DASS etwas nicht stimmt, und laesst einen danach raten, WAS + darueberliegt. Ich habe daran am 06.09.2026 eine Stunde gesucht. + Ein Befund, den man nicht verorten kann, ist ein halber Befund. */ + .map((e) => { + const r = e.getBoundingClientRect(); + const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + const kette = (x) => { + const a = []; + for (let v = x; v && a.length < 4; v = v.parentElement) { + a.push(v.tagName + (v.className && typeof v.className === "string" + ? "." + v.className.split(" ")[0] : "")); + } + return a.join("<"); + }; + const q = oben ? oben.getBoundingClientRect() : null; + return `${kette(e)} "${(e.textContent || "").trim().slice(0, 16)}" ` + + `[${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}]` + + ` <- verdeckt von ${oben ? kette(oben) : "?"}` + + (q ? ` [${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}]` : ""); + }) .slice(0, 3), + /* DIE GEOMETRIE DER KOPFLEISTE, aber nur wenn etwas nicht stimmt. + + Am 06.09.2026 meldete diese Pruefung auf zwoelf Seiten "BUTTON + 'Meine Sicht' verdeckt" -- und mehr nicht. Ich habe danach eine + Stunde gesucht, weil aus der Meldung nicht hervorging, WAS + darueberlag und bei WELCHER Breite. Erst dieser Auszug zeigte es in + einer Zeile: bei 412 px stand `wrap=nowrap`, der Umschalter war auf + 50 px zusammengedrueckt, sein Knopf 104 px breit. + + Bleibt deshalb drin. Ein Befund, den man nicht verorten kann, ist + ein halber Befund -- und er kostet beim naechsten Mal wieder eine + Stunde. */ + kopfDump: (() => { + const k = document.querySelector(".kopfleiste__rechts"); + const l = document.querySelector(".kopfleiste"); + if (!k || !l) return null; + const box = (e) => { const q = e.getBoundingClientRect(); + return `${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}`; }; + const m = document.querySelector(".kopfleiste > .marke"); + return { + w: window.innerWidth, + leiste: box(l) + " wrap=" + getComputedStyle(l).flexWrap, + rechts: box(k) + " wrap=" + getComputedStyle(k).flexWrap, + marke: m ? box(m) + " flex=" + getComputedStyle(m).flex : "keine", + kinder: [...k.children].map((e) => `${e.tagName}.${String(e.className||"").split(" ")[0]} ${box(e)}`), + }; + })(), + /* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die einzige Stelle, ueber die man wieder wegkommt. */ kopfDrin: (() => { @@ -322,7 +380,8 @@ for (const [breite, hoehe, wie] of GERAETE) { + (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "") + (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "") + (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "") - + (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "")); + + (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "") + + (!gut && r.kopfDump ? `\n KOPF: ${JSON.stringify(r.kopfDump)}` : "")); if (!gut) fehler++; seite.off("console", auf); diff --git a/server/pruef-ics.mjs b/server/pruef-ics.mjs new file mode 100644 index 00000000..d4914ac4 --- /dev/null +++ b/server/pruef-ics.mjs @@ -0,0 +1,397 @@ +/* KALENDER ZUM ABONNIEREN — der einzige Weg ohne Anmeldung + + Stufe 3.2 des Plans. Google, Apple und Outlook holen einen Kalender + im Hintergrund ab: ohne Browser, ohne Sitzung, ohne Anmeldemaske. Ein + Kalender, der eine Anmeldung verlangt, laesst sich nicht abonnieren. + + Damit ist der geheime Schluessel in der Adresse der GANZE Schutz + dieses Weges -- und deshalb ist diese Pruefung strenger als die + anderen. Sie fragt vor allem zwei Dinge: + + 1. Sieht wirklich NUR die eigene Person ihre eigenen Termine? + Eine zweite Sichtbarkeitsregel neben der des Workspace waere der + Weg, auf dem ausgerechnet die Tuer ohne Schloss mehr zeigt als + die mit. + 2. Ist der Schluessel wirklich widerrufbar? Ein Geheimnis, das man + nicht zuruecknehmen kann, ist keins. + + Dazu das Format selbst. ICS sieht harmlos aus und ist voller Fallen: + Zeilen ueber 75 Byte schneidet Outlook ab, ein Komma im Titel trennt + das Feld, LF statt CRLF laesst die Datei stumm verwerfen. Geprueft + wird deshalb nicht "es steht etwas da", sondern es wird + ZURUECKGELESEN -- entfaltet, entschluesselt, verglichen. + + GEGENPROBEN durchgehend. + ===================================================================== */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-ics-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4345, "die Kalender-Abo-Pruefung"); + +process.env.PORT = "4345"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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 }); }; +await import("./index.js"); +notbremse(240_000, "pruef-ics"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4345"; + +const PROTOKOLL = join(process.cwd(), "pruef-ics-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +let gemacht = 0; +const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const { heuteLokal } = await import("./helfer-zeit.mjs"); +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 jetzt = new Date().toISOString(); +const heute = heuteLokal(); +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, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +/* Nora gehoert einem ANDEREN Scout -- ihre Termine darf Luna nirgends + sehen, auch nicht ueber den Weg ohne Anmeldung. */ +const idBea = anlegen("Bea", "scout", "CODE-SCOU-0002"); +const idNora = anlegen("Nora", "creator", "CODE-CREA-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idBea, jetzt); + +/* DIE FALLEN DES FORMATS, absichtlich in die Daten gelegt: + ein Komma, ein Semikolon, ein Backslash und ein sehr langer Titel mit + Umlauten. Ohne solche Titel prueft die Datei nur den einfachen Fall + -- und genau der geht immer gut. */ +const HEIKEL = 'Call: Ziele, Technik; Rückblick \\ Ausblick'; +const LANG = "Ein absichtlich sehr langer Titel für den Kalender mit Umlauten " + + "wie Ärger, Öffnung und Übermut, damit die Zeile über fünfundsiebzig Byte hinausgeht"; + +function termin(titel, wer, versatz, ort = null) { + const t = new Date(); + t.setDate(t.getDate() + versatz); + const p = (n) => String(n).padStart(2, "0"); + const beginn = `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T14:30`; + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, ort, creator_id, + teilnehmer_id, erstellt, erstellt_von) VALUES (?,'call',?,45,?,?,?,?,?)`) + .run(titel, beginn, ort, wer, wer, jetzt, idDogi); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const idLunaTermin = termin(HEIKEL, idLuna, 3, "https://meet.example.invalid/raum"); +termin(LANG, idLuna, 5); +termin("Ganz normal", idLuna, 9); +const idNoraTermin = termin("NORAS GEHEIMER TERMIN", idNora, 4); +d.close(); + +/* ---- Werkzeug: ICS zurueckLESEN ---------------------------------------- + Nicht "es steht etwas da", sondern: entfalten, entschluesseln, + vergleichen. Sonst prueft man die eigene Schreibweise. */ +function entfalten(roh) { + /* Eine Fortsetzungszeile beginnt mit Leerzeichen oder Tabulator -- + genau das macht das Format aus. */ + return String(roh).replace(/\r\n[ \t]/g, ""); +} +function entschluesseln(w) { + return String(w) + .replace(/\\n/g, "\n").replace(/\\,/g, ",") + .replace(/\\;/g, ";").replace(/\\\\/g, "\\"); +} +function ereignisse(roh) { + const zeilen = entfalten(roh).split("\r\n"); + const raus = []; + let jetztE = null; + for (const z of zeilen) { + if (z === "BEGIN:VEVENT") { jetztE = {}; continue; } + if (z === "END:VEVENT") { if (jetztE) raus.push(jetztE); jetztE = null; continue; } + if (!jetztE) continue; + const t = z.indexOf(":"); + if (t < 0) continue; + const schluessel = z.slice(0, t).split(";")[0]; + jetztE[schluessel] = entschluesseln(z.slice(t + 1)); + if (schluessel === "DTSTART") jetztE.DTSTART_ROH = z.slice(0, t); + } + return raus; +} + +/* ---- Anmelden und Link holen -------------------------------------------- */ +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 keksLuna = await anmelden("creator", "CODE-CREA-0001"); +const keksBea = await anmelden("scout", "CODE-SCOU-0002"); + +async function linkHolen(keks) { + const a = await fetch(BASIS + "/workspace/api/ics", { + method: "POST", headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, + body: "{}" }); + return (await a.json()).link; +} + +/* ======================================================================= + 1. Der Link entsteht erst auf Wunsch + ======================================================================= */ +melde("=== Der Link entsteht erst auf Wunsch ==="); +{ + const a = await fetch(BASIS + "/workspace/api/ics", { headers: { cookie: keksLuna } }); + const d0 = await a.json(); + /* Ein Geheimnis, das von selbst entsteht, liegt danach herum und + niemand weiss, dass es existiert. */ + ok(d0.link === null, `vor dem ersten Klick gibt es keinen Link (${d0.link})`); +} + +const linkLuna = await linkHolen(keksLuna); +ok(/^http:\/\/127\.0\.0\.1:4345\/workspace\/kalender\/[A-Za-z0-9_-]{40,}\.ics$/.test(linkLuna || ""), + `der Link sieht richtig aus (…${String(linkLuna).slice(-24)})`); + +/* ======================================================================= + 2. OHNE ANMELDUNG abrufbar — darauf beruht die ganze Sache + ======================================================================= */ +melde("\n=== Ohne Anmeldung, wie ein echter Kalender ihn holt ==="); +let icsLuna = ""; +{ + /* Kein Cookie, kein Origin -- genau wie Googles Abholer. */ + const a = await fetch(linkLuna); + icsLuna = await a.text(); + ok(a.status === 200, `HTTP ${a.status} ohne jede Anmeldung`); + ok(/^text\/calendar/.test(a.headers.get("content-type") || ""), + `als "${a.headers.get("content-type")}"`); + ok((a.headers.get("cache-control") || "").includes("no-store"), + "und ohne Zwischenspeicher — ein Kalender, der Altes zeigt, ist schlimmer als keiner"); +} + +/* ======================================================================= + 3. Ist es wirklich ICS? + ======================================================================= */ +melde("\n=== Das Format ==="); +{ + ok(icsLuna.startsWith("BEGIN:VCALENDAR\r\n"), "beginnt mit BEGIN:VCALENDAR"); + ok(icsLuna.trimEnd().endsWith("END:VCALENDAR"), "endet mit END:VCALENDAR"); + + /* CRLF, nicht LF. Outlook verwirft eine Datei mit blossen + Zeilenvorschueben wortlos -- der Kalender bleibt leer, ohne dass + irgendwo etwas rot wird. */ + const nurLF = icsLuna.split("\n").filter((z, i, a) => i < a.length - 1 && !z.endsWith("\r")); + ok(nurLF.length === 0, + nurLF.length ? `${nurLF.length} Zeilen ohne CR: ${nurLF[0].slice(0, 40)}` + : `alle ${icsLuna.split("\r\n").length} Zeilen enden mit CRLF`); + + ok(icsLuna.includes("BEGIN:VTIMEZONE") && icsLuna.includes("TZID:Europe/Berlin"), + "die Zeitzone steht als eigener Block drin (sonst zeigt Outlook eine Stunde daneben)"); + + /* KEINE ZEILE UEBER 75 BYTE. Outlook schneidet ab, statt umzubrechen + -- der Termin verliert dann seinen halben Titel. Gemessen werden + BYTES: Ein Umlaut ist in UTF-8 zwei, und deutsche Titel reissen die + Grenze genau deshalb. */ + const zuLang = icsLuna.split("\r\n") + .filter((z) => Buffer.from(z, "utf8").length > 75); + ok(zuLang.length === 0, + zuLang.length ? `${zuLang.length} Zeilen ueber 75 Byte: "${zuLang[0].slice(0, 50)}…"` + : `keine Zeile ueber 75 Byte (laengste: ${Math.max(...icsLuna.split("\r\n").map((z) => Buffer.from(z, "utf8").length))})`); + + /* GEGENPROBE zur Messung: Ein bewusst zu langer Text MUSS auffallen. + Ohne sie bewiese "0 zu lange Zeilen" nur, dass die Messung nie + etwas findet. */ + ok(Buffer.from("x".repeat(80), "utf8").length > 75, + "Gegenprobe: die Byte-Messung erkennt eine 80 Zeichen lange Zeile als zu lang"); +} + +/* ======================================================================= + 4. Kommen die Termine richtig an? (zurueckgelesen, nicht angesehen) + ======================================================================= */ +melde("\n=== Die Termine, zurueckgelesen ==="); +const evLuna = ereignisse(icsLuna); +{ + ok(evLuna.length === 3, `${evLuna.length} Termine im Kalender (erwartet 3)`); + + /* Der heikle Titel MUSS Zeichen fuer Zeichen zurueckkommen. Komma, + Semikolon und Backslash haben im Format eine Bedeutung -- wer sie + nicht verpackt, zerlegt damit das Feld. */ + const heikel = evLuna.find((e) => String(e.SUMMARY).includes("Rückblick")); + ok(heikel?.SUMMARY === `Call: ${HEIKEL}`, + `Komma, Semikolon und Backslash ueberleben: "${heikel?.SUMMARY}"`); + + /* Und der lange Titel muss nach dem Entfalten wieder GANZ da sein -- + nicht abgeschnitten und nicht mit einem Fragezeichen mitten im + Umlaut (das passiert, wenn man in ein Mehrbyte-Zeichen schneidet). */ + const lang = evLuna.find((e) => String(e.SUMMARY).includes("Übermut")); + ok(lang?.SUMMARY === `Call: ${LANG}`, + `der lange Titel ist nach dem Entfalten vollstaendig (${String(lang?.SUMMARY).length} Zeichen)`); + ok(!/[�?]{1}/.test(String(lang?.SUMMARY).replace(/[?]/g, "")), + "und kein Umlaut ist beim Falten zerbrochen"); + + /* Ortszeit MIT Zonenangabe, nicht nach UTC gerechnet. Wer selbst + umrechnet, muss die Sommerzeit nachbilden und liegt zweimal im Jahr + eine Stunde daneben. */ + const eins = evLuna[0]; + ok(/TZID=Europe\/Berlin/.test(eins?.DTSTART_ROH || ""), + `die Startzeit traegt ihre Zone (${eins?.DTSTART_ROH})`); + ok(/^\d{8}T143000$/.test(eins?.DTSTART || ""), + `und die Uhrzeit stimmt (${eins?.DTSTART})`); + ok(eins?.DURATION === "PT45M", `die Dauer stimmt (${eins?.DURATION})`); + + const mitOrt = evLuna.find((e) => e.LOCATION); + ok(mitOrt?.URL === "https://meet.example.invalid/raum", + `ein Link steht auch im URL-Feld (${mitOrt?.URL}) — daraus macht der Kalender einen Knopf`); + + /* Eine Erinnerung gehoert dazu -- ohne sie ist es nur eine Liste. */ + ok((icsLuna.match(/BEGIN:VALARM/g) || []).length === evLuna.length, + `jeder Termin hat eine Erinnerung (${(icsLuna.match(/BEGIN:VALARM/g) || []).length})`); +} + +/* ======================================================================= + 5. DIE KENNUNG BLEIBT — sonst wird aus einem Termin zwei + ======================================================================= */ +melde("\n=== Die Kennung ueberlebt den naechsten Abruf ==="); +{ + const zweite = ereignisse(await (await fetch(linkLuna)).text()); + const vorher = evLuna.map((e) => e.UID).sort(); + const nachher = zweite.map((e) => e.UID).sort(); + ok(JSON.stringify(vorher) === JSON.stringify(nachher), + `dieselben Kennungen wie beim ersten Abruf (${vorher.length})`); + ok(vorher.every((u) => /^termin-\d+@/.test(u)), + `sie haengen an der Datenbanknummer (${vorher[0]})`); + /* Waere sie nicht stabil, loeschte der Kalender den alten Eintrag und + legte einen neuen an -- aus einem verschobenen Termin wuerden zwei, + und die Erinnerung haengt an der falschen Haelfte. */ + ok(new Set(vorher).size === vorher.length, "und jede kommt genau einmal vor"); +} + +/* ======================================================================= + 6. DIE WICHTIGSTE FRAGE: sieht jemand fremde Termine? + ======================================================================= */ +melde("\n=== Sichtbarkeit — der Weg ohne Anmeldung darf nicht mehr zeigen ==="); +{ + ok(!icsLuna.includes("NORAS GEHEIMER TERMIN"), + "Lunas Kalender enthaelt Noras Termin NICHT"); + + /* GEGENPROBE: Beas Kalender MUSS ihn enthalten -- sie betreut Nora. + Ohne diesen Nachweis bewiese die Zeile darueber nur, dass der + Termin nirgends steht. */ + const linkBea = await linkHolen(keksBea); + const icsBea = await (await fetch(linkBea)).text(); + ok(icsBea.includes("NORAS GEHEIMER TERMIN"), + "Beas Kalender enthaelt ihn sehr wohl — die Messung unterscheidet also"); + ok(!icsBea.includes("Rückblick"), + "und Lunas heikler Termin steht NICHT in Beas Kalender"); + + /* Und die Zahl gehoert in die Bedingung: zwei leere Kalender waeren + sonst der sauberste Befund von allen. */ + const evBea = ereignisse(icsBea); + ok(evLuna.length === 3 && evBea.length === 1, + `Luna ${evLuna.length} Termine, Bea ${evBea.length} — beide Kalender sind gefuellt`); +} + +/* ======================================================================= + 7. Ein falscher Schluessel kommt nirgends hinein + ======================================================================= */ +melde("\n=== Falsche Schluessel ==="); +{ + const proben = [ + ["kurz", "abc"], + ["leer", ""], + ["fast richtig", String(linkLuna).split("/").pop().replace(".ics", "").slice(0, -1) + "A"], + ["mit Punkten", "../../workspace/start"], + ["SQL-Versuch", "' OR '1'='1"], + ]; + let abgewiesen = 0; + for (const [name, s] of proben) { + const a = await fetch(`${BASIS}/workspace/kalender/${encodeURIComponent(s)}.ics`); + const text = a.status === 200 ? await a.text() : ""; + const gut = a.status !== 200 || !text.includes("BEGIN:VEVENT"); + if (gut) abgewiesen++; + else ok(false, `"${name}" kam durch (HTTP ${a.status})`); + } + ok(abgewiesen === proben.length, + `alle ${proben.length} falschen Schluessel abgewiesen`); + + /* GEGENPROBE: Der richtige kommt weiterhin durch. Sonst bewiese das + oben nur, dass der Weg gar nicht funktioniert. */ + const richtig = await fetch(linkLuna); + ok(richtig.status === 200, `der richtige Schluessel funktioniert weiter (HTTP ${richtig.status})`); +} + +/* ======================================================================= + 8. WIDERRUFBAR — ein Geheimnis, das man nicht zuruecknehmen kann, + ist keins + ======================================================================= */ +melde("\n=== Widerrufen ==="); +{ + const neu = await linkHolen(keksLuna); + ok(neu !== linkLuna, "der neue Link ist ein anderer"); + + const alt = await fetch(linkLuna); + ok(alt.status === 404, `der alte ist im selben Moment tot (HTTP ${alt.status})`); + + const jung = await fetch(neu); + const text = await jung.text(); + ok(jung.status === 200 && text.includes("BEGIN:VEVENT"), + `der neue liefert die Termine (HTTP ${jung.status})`); + + /* Abschalten heisst abschalten, nicht "neuer Link". */ + await fetch(BASIS + "/workspace/api/ics", { + method: "DELETE", headers: { cookie: keksLuna, origin: BASIS } }); + const nachAus = await fetch(neu); + ok(nachAus.status === 404, `nach dem Abschalten ist auch er tot (HTTP ${nachAus.status})`); + + const stand = await (await fetch(BASIS + "/workspace/api/ics", + { headers: { cookie: keksLuna } })).json(); + ok(stand.link === null, "und die Verwaltung meldet: kein Link mehr"); +} + +/* ======================================================================= + 9. Fremde Herkunft darf nichts erneuern + ======================================================================= */ +melde("\n=== Fremde Herkunft ==="); +{ + const a = await fetch(BASIS + "/workspace/api/ics", { + method: "POST", + headers: { "Content-Type": "application/json", cookie: keksLuna, + origin: "https://boese.example" }, + body: "{}" }); + ok(a.status === 403, + `eine fremde Seite kann keinen neuen Link erzwingen (HTTP ${a.status})`); + ok(!!heute, `(Bestand vom ${heute}, Termine ${idLunaTermin}/${idNoraTermin} angelegt)`); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${gemacht} Pruefungen`); +melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs index 4ba80c98..1d683f09 100644 --- a/server/pruef-start-ansicht.mjs +++ b/server/pruef-start-ansicht.mjs @@ -177,12 +177,24 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · ")); ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`); - /* Achtzehn seit dem 01.09.2026: Der eigene Steckbrief hat eine eigene - Kachel bekommen, weil er der PERSON gehoert und nicht der - Creator-Akte. Die Farbtoene wurden dafuer neu gerechnet (18 statt - 17 Winkel), nicht eine Farbe dazuerfunden -- der kleinste Abstand - zweier Nachbarn liegt weiterhin bei 100 Grad. */ - ok(r.kacheln.length === 18, `achtzehn Kacheln (${r.kacheln.length})`); + /* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js + gezaehlt (06.09.2026). + + Hier stand `=== 18`, mit einem Kommentar, der erklaerte, warum es + genau achtzehn sind. Am 06.09. kamen Chat, Zahlen und Agentur dazu + -- und die Pruefung meldete drei Tage lang einen Fehler, den es + nicht gab. Eine Pruefung, die bei jeder neuen Kachel rot wird, + erzieht dazu, ihr Rotwerden zu ignorieren; genau dann uebersieht + man das Mal, an dem WIRKLICH eine Kachel fehlt. + + Gezaehlt wird deshalb, was diese Rolle laut bereiche.js sehen + SOLL. Fehlt eine auf dem Bildschirm, faellt es weiterhin auf -- + und eine neue kostet keinen Handgriff mehr. */ + const sollKacheln = await seite.evaluate(() => window.Bereiche.GRUPPEN + .flatMap((g) => g.bereiche).filter((b) => b.rollen.includes("admin")).length); + ok(sollKacheln > 15, `bereiche.js kennt ${sollKacheln} Kacheln fuer DogFather`); + ok(r.kacheln.length === sollKacheln, + `alle ${sollKacheln} Kacheln stehen auf der Seite (${r.kacheln.length})`); ok(/^(Guten Morgen|Guten Mittag|Guten Tag|Guten Abend|Späten Abend|Noch wach), Filipe$/ .test(r.gruss || ""), `Begrüßung nach Tageszeit ("${r.gruss}")`); /* Der Name muss eigenständig ausgezeichnet sein -- er ist das Wichtigste @@ -560,20 +572,55 @@ console.log("\n=== Das Licht folgt dem Zeiger"); ok(lichter.size === farben.length, `und das Licht ist je Kachel ein anderes (${lichter.size} von ${farben.length})`); - /* Zusaetzlich an echten Bildpunkten: Das Licht der Aufgaben-Kachel - (orange) muss mehr Rot als Blau haben, das der Kalender-Kachel - (tuerkis) mehr Blau als Rot. Das kann keine Zeichenkette - vortaeuschen. */ - for (const [name, pruefung] of [["Aufgaben", (p) => p[0] > p[2]], - ["Kalender", (p) => p[2] > p[0]]]) { + /* Zusaetzlich an echten Bildpunkten -- das kann keine Zeichenkette + vortaeuschen. + + GEMESSEN GEGEN DIE EIGENE FARBE DER KACHEL, nicht gegen eine + Farbe aus dem Gedaechtnis (06.09.2026). Hier stand vorher: "das + Licht der Aufgaben-Kachel (orange) muss mehr Rot als Blau haben, + das der Kalender-Kachel (tuerkis) mehr Blau als Rot." Beide + Klammern waren Wissen von aussen -- und als die 21 Farbtoene neu + gerechnet wurden, war der Kalender rosa statt tuerkis. Die + Pruefung meldete einen Fehler, den es nicht gab. + + Jetzt wird der Ton der Kachel selbst ausgelesen und verlangt, + dass das gemessene Licht dieselbe Reihenfolge der Kanaele hat + (welcher ist am staerksten, welcher am schwaechsten). Das ist + genau die Aussage, um die es geht -- "das Licht traegt die Farbe + DIESER Kachel" -- und sie bleibt wahr, egal wie die Palette + gerechnet wird. */ + for (const name of ["Aufgaben", "Kalender", "Agentur"]) { + /* ERST INS BILD ROLLEN. Die Agentur-Kachel liegt weit unten; ihr + Mittelpunkt lag ausserhalb des Fensters, und der Bildpunkt dort + war schwarz (0/0/0). Die Pruefung meldete daraufhin einen + Fehler, der nur bedeutete: "ausserhalb des Bildes gemessen". + Ein Wert von genau null ist fast nie ein Messergebnis. */ + await seite.evaluate((n) => { + [...document.querySelectorAll(".kachel")] + .find((x) => x.querySelector(".kachel__name")?.textContent === n) + ?.scrollIntoView({ block: "center", behavior: "instant" }); + }, name); + await seite.waitForTimeout(250); + const b = await seite.evaluate((n) => { const k = [...document.querySelectorAll(".kachel")] .find((x) => x.querySelector(".kachel__name")?.textContent === n); if (!k) return null; const r = k.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + /* Der Ton der Kachel, in Zahlen. getComputedStyle liefert ihn + als "rgb(a, b, c)". */ + const ton = getComputedStyle(k).getPropertyValue("--ton").trim(); + const zahlen = (ton.match(/\d+/g) || []).map(Number); + const hexTreffer = /^#([0-9a-f]{6})$/i.exec(ton); + const soll = hexTreffer + ? [0, 2, 4].map((i) => parseInt(hexTreffer[1].slice(i, i + 2), 16)) + : zahlen.slice(0, 3); + return { x: r.x + r.width / 2, y: r.y + r.height / 2, soll, ton }; }, name); - if (!b) continue; + if (!b || b.soll.length !== 3) { + ok(false, `"${name}": Farbton nicht lesbar (${b?.ton})`); + continue; + } await seite.mouse.move(b.x, b.y); await seite.waitForTimeout(350); const bild = await seite.screenshot({ type: "png" }); @@ -585,11 +632,28 @@ console.log("\n=== Das Licht folgt dem Zeiger"); const s = i.width / window.innerWidth; return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data]; }, { b64: bild.toString("base64"), x: b.x, y: b.y }); - ok(pruefung(p), `das Licht auf "${name}" hat die richtige Farbe ` - + `(rot ${p[0]}, grün ${p[1]}, blau ${p[2]})`); + /* Die REIHENFOLGE der Kanaele muss uebereinstimmen. Absolute + Werte koennen es nicht: Das Licht ist stark abgedunkelt und + liegt auf einem dunklen Grund, seine Zahlen sind ein Bruchteil + der Tonfarbe. Welcher Kanal der staerkste ist, bleibt davon + unberuehrt -- und genau das beweist, dass es DIESE Farbe ist. */ + const reihe = (v) => [0, 1, 2].sort((a, c) => v[c] - v[a]).join(""); + const sollReihe = reihe(b.soll); + const istReihe = reihe(p); + ok(sollReihe === istReihe, + `das Licht auf "${name}" traegt den Ton der Kachel ` + + `(${b.ton}: ${b.soll.join("/")} → gemessen ${p[0]}/${p[1]}/${p[2]}, ` + + `Reihenfolge ${istReihe} gegen ${sollReihe})`); } await seite.mouse.move(2, 2); + /* UND WIEDER GANZ NACH OBEN. Der Abschnitt oben rollt zu den + Kacheln; was danach kommt, misst am oberen Rand. Ohne diese Zeile + meldeten die folgenden Pruefungen "der Fleck steht nicht links" -- + er stand sehr wohl, nur ausserhalb des Bildausschnitts. Eine + Pruefung, die den Zustand hinterlaesst, den sie vorgefunden hat, + ist die einzige, der die naechste glauben kann. */ + await seite.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); await seite.waitForTimeout(300); } ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`); diff --git a/server/pruef-tempo-workspace.mjs b/server/pruef-tempo-workspace.mjs index 836b4a1f..7e97fc1f 100644 --- a/server/pruef-tempo-workspace.mjs +++ b/server/pruef-tempo-workspace.mjs @@ -270,21 +270,43 @@ for (const datei of SEITEN) { await seite.waitForTimeout(400); const zweitens = await seite.evaluate(() => new Promise((fertig) => { let summe = 0; + /* BEIM ZWEITEN MESSEN WIRD AUCH FESTGEHALTEN, WAS SPRINGT. + "Layout springt ueber 0.6" allein sagt, DASS etwas nicht + stimmt, und laesst einen danach raten -- ich habe deswegen am + 06.09.2026 vergeblich gesucht, weil der Sprung sich in einem + nachgebauten Aufbau nicht zeigte. Die Quellen stehen im + Ereignis selbst; sie nicht mitzunehmen war reine + Nachlaessigkeit. */ + const wer = []; try { const b = new PerformanceObserver((liste) => { - for (const e of liste.getEntries()) if (!e.hadRecentInput) summe += e.value; + for (const e of liste.getEntries()) { + if (e.hadRecentInput) continue; + summe += e.value; + for (const q of (e.sources || [])) { + const n = q.node; + wer.push(`${n ? (n.tagName || "?") + (n.className && typeof n.className === "string" + ? "." + n.className.split(" ")[0] : "") : "?"}` + + ` ${Math.round(q.previousRect.top)}->${Math.round(q.currentRect.top)}`); + } + } }); b.observe({ type: "layout-shift", buffered: true }); - setTimeout(() => { b.disconnect(); fertig(Math.round(summe * 1000) / 1000); }, 900); - } catch { fertig(0); } + setTimeout(() => { + b.disconnect(); + fertig({ wert: Math.round(summe * 1000) / 1000, wer: [...new Set(wer)].slice(0, 4) }); + }, 900); + } catch { fertig({ wert: 0, wer: [] }); } })); - if (zweitens > GRENZE_SPRUNG) { - springende.push(`${datei} ${sprung} und erneut ${zweitens}`); + if (zweitens.wert > GRENZE_SPRUNG) { + springende.push(`${datei} ${sprung} und erneut ${zweitens.wert}` + + (zweitens.wer.length ? ` + es springt: ${zweitens.wer.join(" | ")}` : "")); } else { /* Nicht verschweigen: Der Ausschlag war da, er hat sich nur nicht bestaetigt. Wer die Zahl haeufig sieht, weiss, dass die Seite nahe an der Grenze liegt. */ - schwankend.push(`${datei} ${sprung} → beim Nachmessen ${zweitens}`); + schwankend.push(`${datei} ${sprung} → beim Nachmessen ${zweitens.wert}`); } } if (anfragen > GRENZE_ANFRAGEN || bytes > GRENZE_BYTES) { diff --git a/server/pruef-workspace-umzug.mjs b/server/pruef-workspace-umzug.mjs index b34dc9c6..169a2aad 100644 --- a/server/pruef-workspace-umzug.mjs +++ b/server/pruef-workspace-umzug.mjs @@ -118,8 +118,23 @@ geprueft++; if (!zielStimmt) fehler.push(`Ziel ist "${NEUE_ADRESSE}", erwartet https://workspace.dogfather-universe.com`); const SOLL = FAELLE.length + 11; -console.log(`${geprueft} Pruefungen gelaufen (erwartet ${SOLL}), ${fehler.length} Beanstandungen.`); -console.log(`Gegenprobe (alt abgeschaltet, neu erreichbar): ${gegenprobe ? "ja" : "NEIN"}`); + +/* IN DERSELBEN SCHREIBWEISE WIE ALLE ANDEREN (06.09.2026). + + Diese Datei meldete ihr Ergebnis in eigenen Worten ("31 Pruefungen + gelaufen") statt in den Zeilen ` ok ` / ` FEHL `, nach denen + tools/alles-pruefen.mjs zaehlt. Der Gesamtlauf las daraus NULL + Pruefungen -- und weil "null Pruefungen" dort zu Recht als Fehler + gilt, stand hier bei jedem Lauf ein FEHL, obwohl alle 31 Punkte + bestanden. + + Das ist die unangenehmste Sorte Fehlalarm: Er kommt immer, er ist + immer unbegruendet, und nach drei Malen liest ihn niemand mehr. Genau + dann faellt der eine echte nicht mehr auf. Deshalb schreibt diese + Datei jetzt dieselbe Sprache wie ihre Nachbarn. */ +console.log(` ok ${geprueft} Faelle geprueft (erwartet ${SOLL})`); +console.log(` ${gegenprobe ? "ok " : "FEHL"} Gegenprobe: alt abgeschaltet, neu erreichbar`); +console.log(` ${fehler.length === 0 ? "ok " : "FEHL"} ${fehler.length} Beanstandungen`); for (const f of fehler) console.log(" FEHL " + f); if (geprueft !== SOLL) { console.log("FEHL: Anzahl stimmt nicht."); process.exit(1); } diff --git a/server/workspace-ics.js b/server/workspace-ics.js new file mode 100644 index 00000000..ffbb4f5b --- /dev/null +++ b/server/workspace-ics.js @@ -0,0 +1,440 @@ +/* ===================================================================== + workspace-ics.js — der eigene Kalender zum Abonnieren. + + Stufe 3.2 des Plans vom 31.08.2026. + + --------------------------------------------------------------------- + WARUM DAS MEHR WERT IST ALS NOCH EINE ERINNERUNG + + Der Workspace hat seit dem 05.09. Web Push. Trotzdem bleibt ein + Problem, das keine Benachrichtigung loest: Die Termine stehen in + EINEM Kalender, und alle anderen Kalender im Leben eines Menschen + wissen nichts davon. Wer am Dienstag um drei einen Zahnarzttermin + eintraegt, sieht nicht, dass da schon ein Call steht. + + Ein abonnierter Kalender dreht das um: Die Termine kommen dorthin, wo + ohnehin nachgesehen wird. Einmal eingerichtet, nie wieder anfassen. + + --------------------------------------------------------------------- + DIE DREI ENTSCHEIDUNGEN + + 1. EIN GEHEIMER LINK, KEIN LOGIN. + Google, Apple und Outlook holen den Kalender im Hintergrund ab -- + ohne Browser, ohne Sitzung, ohne Anmeldemaske. Ein Kalender, der + eine Anmeldung verlangt, laesst sich schlicht nicht abonnieren. + Der Schluessel IST deshalb der Zugang, und er ist entsprechend + lang (32 Byte Zufall). + + 2. DER LINK MUSS WIDERRUFBAR SEIN. + Ein Geheimnis, das man nicht zuruecknehmen kann, ist keins. Wer + seinen Link versehentlich weitergibt, drueckt auf "Neuen Link + erzeugen" -- der alte ist im selben Moment tot. Deshalb steht der + Schluessel in einer eigenen Tabelle mit genau einer Zeile je + Person und wird beim Erneuern ueberschrieben. + + 3. NUR LESEN, NUR EIGENES. + Der Link kann nichts aendern und nichts anderes zeigen als die + Termine, die diese Person auch im Workspace sieht -- dieselbe + Regel (termineSichtbar), nicht eine zweite daneben. Zwei + Sichtbarkeitsregeln laufen mit der Zeit auseinander, und dann + zeigt ausgerechnet der Weg ohne Anmeldung mehr als der mit. + + --------------------------------------------------------------------- + WAS BEWUSST NICHT DRIN STEHT + + Interne Notizen, Bewertungen, Protokolle. Ein abonnierter Kalender + landet auf Geraeten, ueber die wir nichts wissen -- auf dem Handy + eines Creators, in einem Google-Konto, vielleicht auf einem + Familien-Tablet. Dort gehoert hin, WANN etwas ist und MIT WEM, mehr + nicht. Die Beschreibung wird deshalb gekuerzt und enthaelt keinen + internen Vermerk. + ===================================================================== */ + +import express from "express"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + db, protokolliere, echteIp, sitzungLesen, termineSichtbar, externSql, +} from "./workspace.js"; + +export const icsRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); + +/* ---------- Der Schluessel --------------------------------------------- */ + +/** Legt die Tabelle an. Eine Zeile je Person -- mehr braucht es nicht, + * und "genau eine" macht das Widerrufen zu einem Ueberschreiben statt + * zu einem Aufraeumen. */ +function tabelle() { + db().exec(` + CREATE TABLE IF NOT EXISTS ics_schluessel ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + schluessel TEXT NOT NULL UNIQUE, + erstellt TEXT NOT NULL, + zuletzt TEXT + )`); +} + +/** Holt den Schluessel dieser Person -- oder legt einen an. + * `neu = true` erzwingt einen frischen; der alte ist danach tot. */ +export function schluesselFuer(personId, { neu = false } = {}) { + tabelle(); + const d = db(); + if (!neu) { + const da = d.prepare("SELECT schluessel FROM ics_schluessel WHERE person_id = ?") + .get(personId); + if (da) return da.schluessel; + } + /* 32 Byte aus randomBytes, als Basis64url. Das ist der ganze Schutz + dieses Weges -- entsprechend lang. Kein Zeitstempel und keine + Personennummer darin: Beides waere ratbar. */ + const s = randomBytes(32).toString("base64url"); + d.prepare(` + INSERT INTO ics_schluessel (person_id, schluessel, erstellt) + VALUES (?,?,?) + ON CONFLICT(person_id) DO UPDATE SET schluessel = excluded.schluessel, + erstellt = excluded.erstellt, + zuletzt = NULL`) + .run(personId, s, jetzt()); + return s; +} + +/** Wem gehoert dieser Schluessel? + * + * VERGLICHEN WIRD IN GLEICHBLEIBENDER ZEIT. Ein gewoehnlicher Vergleich + * bricht beim ersten falschen Zeichen ab; wer die Antwortzeit misst, + * kann daraus Zeichen fuer Zeichen den Schluessel erraten. Bei einem + * Geheimnis, das ohne Anmeldung gilt, ist das kein theoretischer + * Einwand. + * + * Die Suche selbst laeuft ueber den Index (UNIQUE) -- deshalb wird + * danach NOCH EINMAL Byte fuer Byte verglichen, damit das Ergebnis + * nicht allein von der Datenbank abhaengt. */ +function personZu(schluessel) { + tabelle(); + const s = String(schluessel || ""); + if (s.length < 40 || s.length > 64 || !/^[A-Za-z0-9_-]+$/.test(s)) return null; + + const z = db().prepare(` + SELECT k.person_id, k.schluessel, p.name, p.rolle, p.aktiv + FROM ics_schluessel k JOIN personen p ON p.id = k.person_id + WHERE k.schluessel = ?`).get(s); + if (!z || !z.aktiv) return null; + + const a = Buffer.from(z.schluessel, "utf8"); + const b = Buffer.from(s, "utf8"); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + return { id: z.person_id, name: z.name, rolle: z.rolle }; +} + +/* ---------- ICS bauen ---------------------------------------------------- */ + +/** Text so verpacken, wie RFC 5545 es verlangt. + * + * Backslash, Semikolon, Komma und Zeilenumbruch haben im Format eine + * Bedeutung. Ein Titel wie "Call: Ziele, Technik" wuerde ohne das hier + * am Komma auseinanderfallen -- und zwar so, dass der Kalender + * entweder Unsinn anzeigt oder die ganze Datei verwirft. */ +function text(roh) { + return String(roh ?? "") + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r?\n/g, "\\n"); +} + +/** Zeilen auf 75 Oktette falten. + * + * Das Format schreibt es vor, und Outlook nimmt es ernst: Eine lange + * Zeile wird dort abgeschnitten, nicht umgebrochen -- der Termin + * verliert dann seinen halben Titel. + * + * Gezaehlt werden BYTES, nicht Zeichen. Ein Umlaut ist in UTF-8 zwei + * Byte; wer Zeichen zaehlt, ueberschreitet die Grenze bei deutschen + * Titeln regelmaessig. Und es wird nie MITTEN in ein Zeichen + * geschnitten -- sonst steht dort ein Fragezeichen. */ +function falten(zeile) { + const bytes = Buffer.from(zeile, "utf8"); + if (bytes.length <= 75) return zeile; + const stuecke = []; + let ab = 0; + let grenze = 75; + while (ab < bytes.length) { + let bis = Math.min(ab + grenze, bytes.length); + /* Nicht in ein Mehrbyte-Zeichen hineinschneiden: Fortsetzungsbytes + beginnen mit 10xxxxxx. */ + while (bis > ab && bis < bytes.length && (bytes[bis] & 0xc0) === 0x80) bis--; + stuecke.push(bytes.subarray(ab, bis).toString("utf8")); + ab = bis; + grenze = 74; // Folgezeilen beginnen mit einem Leerzeichen + } + return stuecke.join("\r\n "); +} + +/** "2026-09-08T14:30" (Ortszeit) -> "20260908T143000". + * + * Die Termine stehen in der Datenbank als ORTSZEIT ohne Zone. Genau so + * gehen sie hier hinaus, zusammen mit TZID=Europe/Berlin -- die + * Umrechnung macht der Kalender des Empfaengers. Wer hier selbst nach + * UTC rechnet, muss die Sommerzeit nachbilden und liegt zweimal im + * Jahr eine Stunde daneben. */ +function stempel(beginn) { + const t = String(beginn || "").replace(" ", "T"); + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(t); + if (!m) return null; + return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}00`; +} + +const ART_NAME = { call: "Call", termin: "Termin", review: "Review", frist: "Frist" }; + +/** Die Zeitzone als eigener Block. + * + * Ohne ihn kennt ein Kalender "Europe/Berlin" zwar meist trotzdem -- + * aber "meist" ist bei Terminen zu wenig. Outlook zeigt Termine sonst + * je nach Fassung eine Stunde daneben, und das faellt erst auf, wenn + * jemand zu spaet kommt. */ +const ZEITZONE = [ + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", +]; + +export function icsBauen(person, termine, gastgeber) { + const zeilen = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Dogfather Universe//Creator Workspace//DE", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${text("Creator Workspace")}`, + "X-WR-TIMEZONE:Europe/Berlin", + /* Wie oft der Kalender nachsehen soll. Eine Stunde ist der + Kompromiss: Google haelt sich ohnehin selten daran, Apple schon. + Kuerzer waere Hoeflichkeit gegenueber dem eigenen Server. */ + "REFRESH-INTERVAL;VALUE=DURATION:PT1H", + "X-PUBLISHED-TTL:PT1H", + ...ZEITZONE, + ]; + + const jetztStempel = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z"; + + for (const t of termine) { + const start = stempel(t.beginn); + if (!start) continue; // unlesbares Datum: lieber weglassen + const dauer = Math.max(5, Math.min(Number(t.dauer_min) || 30, 1440)); + + /* Die Kennung muss ueber Jahre DIESELBE bleiben. Aendert sie sich, + loescht der Kalender den alten Eintrag und legt einen neuen an -- + aus einem verschobenen Termin werden dann zwei, und die + Erinnerung des Nutzers haengt an der falschen Haelfte. Deshalb + aus der Datenbanknummer gebildet und aus nichts sonst. */ + zeilen.push("BEGIN:VEVENT"); + zeilen.push(`UID:termin-${t.id}@workspace.dogfather-universe.com`); + zeilen.push(`DTSTAMP:${jetztStempel}`); + zeilen.push(`DTSTART;TZID=Europe/Berlin:${start}`); + zeilen.push(`DURATION:PT${dauer}M`); + + const art = ART_NAME[t.art] || "Termin"; + const mit = t.teilnehmer_name || t.creator_name || null; + zeilen.push(falten(`SUMMARY:${text(`${art}: ${t.titel}`)}`)); + + /* NUR WANN UND MIT WEM. Interne Notizen bleiben draussen -- dieser + Kalender landet auf Geraeten, ueber die wir nichts wissen. */ + const teile = []; + if (mit) teile.push(`Mit: ${mit}`); + if (t.beschreibung) teile.push(String(t.beschreibung).slice(0, 500)); + teile.push(`Im Workspace: ${gastgeber}/workspace/kalender.html`); + zeilen.push(falten(`DESCRIPTION:${text(teile.join("\n"))}`)); + + if (t.ort) zeilen.push(falten(`LOCATION:${text(t.ort)}`)); + /* Ein Link im eigenen Feld -- Apple und Google machen daraus einen + Knopf "Beitreten". */ + if (t.ort && /^https?:\/\//i.test(String(t.ort))) { + zeilen.push(falten(`URL:${text(t.ort)}`)); + } + + /* Erledigte Termine bleiben drin, aber als abgesagt gekennzeichnet: + Sie einfach verschwinden zu lassen hiesse, dass ein Kalender, der + gestern noch etwas zeigte, heute schweigt -- und niemand weiss, + ob der Termin weg ist oder die Anbindung kaputt. */ + zeilen.push(`STATUS:${t.erledigt ? "CANCELLED" : "CONFIRMED"}`); + + /* Eine Erinnerung 15 Minuten vorher. Der Kalender kann sie + ueberschreiben; ohne sie gaebe es gar keine. */ + if (!t.erledigt) { + zeilen.push("BEGIN:VALARM"); + zeilen.push("ACTION:DISPLAY"); + zeilen.push("TRIGGER:-PT15M"); + zeilen.push(falten(`DESCRIPTION:${text(`${art}: ${t.titel}`)}`)); + zeilen.push("END:VALARM"); + } + zeilen.push("END:VEVENT"); + } + + zeilen.push("END:VCALENDAR"); + /* CRLF, nicht LF. Das Format schreibt es vor, und Outlook verwirft + eine Datei mit blossen Zeilenvorschueben wortlos. */ + return zeilen.join("\r\n") + "\r\n"; +} + +/* ===================================================================== + Die Wege + ===================================================================== */ + +const SPALTEN = ` + t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort, + t.erledigt, + ${externSql("pc.name", "t.teilnehmer_extern")} AS creator_name, + ${externSql("pt.name", "t.teilnehmer_extern")} AS teilnehmer_name`; +const VERBUND = ` + FROM termine t + LEFT JOIN personen pc ON pc.id = t.creator_id + LEFT JOIN personen pt ON pt.id = t.teilnehmer_id`; + +/** Der abonnierbare Kalender. OHNE Anmeldung, mit Schluessel. */ +/* DER PFAD ENDET AUF .ics -- viele Kalenderprogramme bestehen darauf. + Die Endung wird hier aber SELBST abgeschnitten und nicht dem + Wegemuster ueberlassen (`:schluessel.ics`). Wie Express einen Punkt + in einem Muster behandelt, haengt an der Fassung seiner + Wegebibliothek -- und ein Weg, der nach einem Update still nicht mehr + trifft, waere hier besonders unangenehm: Der Kalender hoert einfach + auf, sich zu aktualisieren, und niemand bekommt eine Meldung. */ +icsRouter.get("/workspace/kalender/:datei", (req, res) => { + try { + const datei = String(req.params.datei || ""); + if (!datei.endsWith(".ics")) { + return res.status(404).type("text/plain").send("Nicht gefunden."); + } + const person = personZu(datei.slice(0, -4)); + if (!person) { + /* 404, nicht 401: Was es fuer diesen Schluessel nicht gibt, soll + auch nicht bestaetigen, dass es die Adresse gibt. */ + return res.status(404).type("text/plain").send("Nicht gefunden."); + } + + /* DIESELBE Sichtbarkeitsregel wie im Workspace. Eine zweite hier + liefe mit der Zeit auseinander -- und dann zeigte ausgerechnet + der Weg ohne Anmeldung mehr als der mit. */ + const { wo, werte } = termineSichtbar(person, "t"); + + /* Ein Jahr rueckwaerts, ein Jahr vorwaerts. Alles auszuliefern + waere bei jedem Abruf die ganze Geschichte; ein Kalender braucht + sie nicht, und die Datei waechst sonst ohne Ende. */ + const heute = new Date(); + const vor = new Date(heute.getTime() - 365 * 86400000).toISOString().slice(0, 10); + const nach = new Date(heute.getTime() + 365 * 86400000).toISOString().slice(0, 10); + + const termine = db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} AND t.beginn >= ? AND t.beginn <= ? + ORDER BY t.beginn`).all(...werte, vor, nach + "T23:59:59"); + + const gastgeber = `${req.protocol}://${req.get("host")}`; + const ics = icsBauen(person, termine, gastgeber); + + db().prepare("UPDATE ics_schluessel SET zuletzt = ? WHERE person_id = ?") + .run(jetzt(), person.id); + + res.set("Content-Type", "text/calendar; charset=utf-8"); + res.set("Content-Disposition", 'inline; filename="creator-workspace.ics"'); + /* Nicht zwischenspeichern: Ein Kalender, der eine alte Fassung + zeigt, ist schlimmer als keiner -- man glaubt ihm ja. */ + res.set("Cache-Control", "no-store"); + /* Und nicht in einer fremden Seite einbetten lassen. */ + res.set("X-Content-Type-Options", "nosniff"); + res.send(ics); + } catch (fehler) { + console.error("[ics] Ausliefern:", fehler?.message); + res.status(503).type("text/plain").send("Gerade nicht verfuegbar."); + } +}); + +/* ---- Die Verwaltung des eigenen Links (mit Anmeldung) ------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +/** Der eigene Link. JEDE Rolle hat einen -- es sind die eigenen Termine. */ +icsRouter.get("/workspace/api/ics", angemeldet, (req, res) => { + try { + tabelle(); + const da = db().prepare( + "SELECT schluessel, erstellt, zuletzt FROM ics_schluessel WHERE person_id = ?") + .get(req.person.id); + const gastgeber = `${req.protocol}://${req.get("host")}`; + res.json({ + /* Der Link wird NICHT von selbst angelegt. Ein Geheimnis, das + entsteht, ohne dass jemand es wollte, liegt danach herum -- + angelegt wird er beim ersten Klick auf "Link erzeugen". */ + link: da ? `${gastgeber}/workspace/kalender/${da.schluessel}.ics` : null, + erstellt: da?.erstellt || null, + zuletzt: da?.zuletzt || null, + }); + } catch (fehler) { + console.error("[ics] Lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Anlegen oder erneuern. Erneuern macht den alten Link im selben + * Moment ungueltig -- das ist der Zweck. */ +icsRouter.post("/workspace/api/ics", angemeldet, gleicheHerkunft, + express.json({ limit: "2kb" }), (req, res) => { + try { + const vorhanden = !!db().prepare( + "SELECT 1 FROM ics_schluessel WHERE person_id = ?").get(req.person.id); + const s = schluesselFuer(req.person.id, { neu: true }); + const gastgeber = `${req.protocol}://${req.get("host")}`; + protokolliere(vorhanden ? "ics_erneuert" : "ics_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + }); + res.json({ link: `${gastgeber}/workspace/kalender/${s}.ics`, erneuert: vorhanden }); + } catch (fehler) { + console.error("[ics] Erzeugen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Abschalten. Danach gibt es keinen Link mehr -- nicht einen neuen. */ +icsRouter.delete("/workspace/api/ics", angemeldet, gleicheHerkunft, (req, res) => { + try { + tabelle(); + const e = db().prepare("DELETE FROM ics_schluessel WHERE person_id = ?") + .run(req.person.id); + protokolliere("ics_abgeschaltet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + }); + res.json({ ok: true, entfernt: Number(e.changes) }); + } catch (fehler) { + console.error("[ics] Abschalten:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/tools/kachel-farben.mjs b/tools/kachel-farben.mjs index 330fcd45..7fbe7f7c 100644 --- a/tools/kachel-farben.mjs +++ b/tools/kachel-farben.mjs @@ -52,8 +52,17 @@ const hex = (rgb) => "#" + rgb.map((v) => /* 01.09.2026: 18 statt 17. Der eigene Steckbrief hat eine eigene Kachel bekommen (vorher teilte er sich die Seite mit der Creator-Akte). Damit aendert sich der Abstand zwischen allen Toenen - -- deshalb wird hier neu gerechnet und nicht eine Farbe dazuerfunden. */ -const ANZAHL = 18; + -- deshalb wird hier neu gerechnet und nicht eine Farbe dazuerfunden. + + 06.09.2026: 21. Drei Kacheln sind an einem Tag dazugekommen -- Chat, + Zahlen und Agentur -- und jede hatte sich zunaechst einen Ton mit + einer bestehenden geteilt. Das faellt am Rechner kaum auf und ist + trotzdem falsch: Filipe wollte ausdruecklich, dass JEDE Kachel ihre + eigene Farbe hat, und pruef-start-ansicht hat es gemessen (18 Farben + auf 21 Kacheln). Eine Farbe dazuzuerfinden waere der bequeme Weg und + der falsche -- sie laege dann irgendwo zwischen zweien und saehe nach + Zufall aus. Also wird der ganze Kreis neu geteilt. */ +const ANZAHL = 21; const winkel = (i) => (i * 360 / ANZAHL + 250) % 360; function farbe(i) { let C = C_MAX; @@ -62,9 +71,9 @@ function farbe(i) { } const KACHELN = [ - "Dashboard", "Aufgaben", "Kalender", "Calls", "Dateien", - "Mein Steckbrief", "Creator-Profile", "Start-Check", "LIVE", "Content", "Community", - "Technik", "Schutz", "Reports", + "Dashboard", "Aufgaben", "Kalender", "Calls", "Chat", "Dateien", + "Zahlen", "Mein Steckbrief", "Creator-Profile", "Start-Check", "LIVE", "Content", + "Community", "Technik", "Schutz", "Agentur", "Reports", "Personen", "Scout", "Bibliothek", "Automationen", ]; const N = (n) => KACHELN.indexOf(n); @@ -72,13 +81,19 @@ const N = (n) => KACHELN.indexOf(n); /* Nachbarschaft aus dem tatsaechlichen Aufbau der Seite: drei Spalten, Dashboard ueber zwei. Nebeneinander UND untereinander. */ const NACHBARN = [ + /* Taeglich: Dashboard ueber zwei Spalten, dann Aufgaben daneben; + darunter Kalender, Calls, Chat; darunter Dateien. */ ["Dashboard", "Aufgaben"], ["Dashboard", "Kalender"], ["Dashboard", "Calls"], - ["Aufgaben", "Dateien"], ["Kalender", "Calls"], ["Calls", "Dateien"], - ["Mein Steckbrief", "Creator-Profile"], ["Creator-Profile", "Start-Check"], - ["Mein Steckbrief", "LIVE"], ["Start-Check", "LIVE"], ["Start-Check", "Content"], - ["Content", "Community"], ["Community", "Technik"], ["Schutz", "Reports"], - ["Creator-Profile", "Content"], ["Start-Check", "Community"], ["LIVE", "Technik"], - ["Content", "Schutz"], ["Community", "Reports"], + ["Aufgaben", "Chat"], ["Kalender", "Calls"], ["Calls", "Chat"], + ["Kalender", "Dateien"], + /* Rund um den Creator: elf Kacheln in drei Spalten. */ + ["Zahlen", "Mein Steckbrief"], ["Mein Steckbrief", "Creator-Profile"], + ["Zahlen", "Start-Check"], ["Mein Steckbrief", "LIVE"], ["Creator-Profile", "Content"], + ["Start-Check", "LIVE"], ["LIVE", "Content"], + ["Start-Check", "Community"], ["LIVE", "Technik"], ["Content", "Schutz"], + ["Community", "Technik"], ["Technik", "Schutz"], + ["Community", "Agentur"], ["Technik", "Reports"], ["Agentur", "Reports"], + /* Team & System. */ ["Personen", "Scout"], ["Scout", "Bibliothek"], ["Personen", "Automationen"], ].map(([a, b]) => [N(a), N(b)]); @@ -91,6 +106,10 @@ const WUNSCH = { LIVE: 17, Content: 38, Technik: 81, Reports: 144, Personen: 59, Schutz: 250, Community: 335, Dashboard: 250, Kalender: 208, Bibliothek: 271, "Start-Check": 165, + /* Die drei Neuen (06.09.2026): Chat gruen wie ein gelesener Haken, + Zahlen blau wie eine Messung, Agentur orange -- die Farbe, die + "von aussen" sagt, ohne zu warnen. */ + Chat: 150, Zahlen: 265, Agentur: 40, }; function wunschNaehe(z) { let punkte = 0; @@ -119,7 +138,12 @@ const zufall = () => (saat = (saat * 1103515245 + 12345) % 2147483648) / 2147483 let beste = null, besteNote = -1; for (let lauf = 0; lauf < 900; lauf++) { const z = [...Array(ANZAHL).keys()]; - for (let i = 16; i > 0; i--) { const j = Math.floor(zufall() * (i + 1)); [z[i], z[j]] = [z[j], z[i]]; } + /* ANZAHL - 1, nicht 16. Hier stand eine feste 16 -- bei achtzehn + Kacheln wurden die letzten beiden also nie mitgemischt, und der + Suchraum war stillschweigend kleiner als gedacht. Faellt nicht auf, + weil trotzdem eine Loesung herauskommt; nur eben nicht unbedingt + die beste. */ + for (let i = ANZAHL - 1; i > 0; i--) { const j = Math.floor(zufall() * (i + 1)); [z[i], z[j]] = [z[j], z[i]]; } let note = bewerte(z), besser = true; while (besser) { besser = false; diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index f365e53f..a995dda5 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -802,3 +802,95 @@ .k-dabei__zeichen { border: 1px solid CanvasText; } .k-dabei__haken path { stroke: CanvasText; } } + +/* ---------- Kalender abonnieren (06.09.2026) ----------------------------- + + Zugeklappt und unauffaellig: Man richtet die Sache EINMAL ein und + sieht sie danach nie wieder an. Ein dauerhaft offener Kasten draengte + sich vor die Termine, um die es hier geht. */ +.k-abo { + margin: 0 0 22px; + background: var(--flaeche); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + overflow: hidden; +} +.k-abo__kopf { + display: grid; gap: 2px; + padding: 13px 16px; cursor: pointer; + list-style: none; + transition: background var(--tempo); +} +.k-abo__kopf::-webkit-details-marker { display: none; } +.k-abo__kopf:hover, .k-abo__kopf:focus-visible { background: rgba(150, 186, 220, .05); } +.k-abo__titel { font-size: .95rem; color: var(--text); } +.k-abo__unter { font-size: .8rem; color: var(--text-still); } + +/* Ein Pfeil, der sich dreht -- das einzige Zeichen, das sagt "hier ist + mehr". Ohne ihn sieht die Zeile aus wie eine Ueberschrift. */ +.k-abo__kopf::after { + content: ''; position: absolute; right: 18px; margin-top: 6px; + width: 8px; height: 8px; + border-right: 2px solid var(--text-still); + border-bottom: 2px solid var(--text-still); + transform: rotate(45deg); + transition: transform var(--tempo); +} +.k-abo { position: relative; } +.k-abo[open] .k-abo__kopf::after { transform: rotate(-135deg); } + +/* ZUGEKLAPPT HEISST WEG -- ausdruecklich, nicht nur laut Browser. + + Dasselbe wie bei den Zeitbloecken in calls.css, und aus demselben + Grund: Chromium versteckt den Inhalt eines geschlossenen
+ ueber `content-visibility`, nicht ueber `display`. Die Kaesten + behalten dabei eine Groesse -- gemessen von pruef-breiten: Der Knopf + "Link erzeugen" lag bei 768 px unter einem Filter-Chip, obwohl der + Kasten zugeklappt war. Fuer das Auge unsichtbar, fuer jede Messung + (und fuer die Suchfunktion des Browsers) vorhanden. */ +.k-abo:not([open]) .k-abo__inhalt { display: none; } + +.k-abo__inhalt { + padding: 0 16px 16px; + border-top: 1px solid var(--rand); + padding-top: 14px; +} +.k-abo__satz { + margin: 0 0 14px; font-size: .85rem; line-height: 1.65; color: var(--text-leise); +} + +.k-abo__zeile { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } +.k-abo__feld { + flex: 1 1 320px; min-width: 0; height: 42px; + padding: 0 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .8rem; color: var(--text-leise); + background: rgba(0, 0, 0, .25); + border: 1px solid var(--rand); border-radius: var(--radius-klein); +} + +.k-abo__stand { + margin: 0 0 14px; font-size: .8rem; color: var(--text-still); +} +/* Solange noch nie abgeholt wurde, ist das eine offene Frage -- kein + Fehler, aber auch keine Bestaetigung. */ +.k-abo__stand[data-warten="ja"] { color: #d9a441; } + +.k-abo__knoepfe { display: flex; gap: 10px; flex-wrap: wrap; } + +.k-abo__hilfe { + margin: 16px 0 0; padding-top: 14px; + border-top: 1px solid var(--rand); + display: grid; gap: 8px; + font-size: .82rem; line-height: 1.6; color: var(--text-leise); +} +.k-abo__weg { display: block; } + +@media (max-width: 640px) { + .k-abo__feld { flex: 1 1 100%; } + .k-abo__knoepfe > * { flex: 1 1 auto; } +} + +@media (prefers-reduced-motion: reduce) { + .k-abo__kopf, .k-abo__kopf::after { transition: none; } +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 7afc5453..21a6a1af 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -187,6 +187,21 @@ body.start > main { position: relative; z-index: 1; } /* ---------- Kopfleiste ------------------------------------------------ */ +/* DIE AEUSSERE LEISTE BRICHT NICHT UM -- der Schriftzug gibt nach. + + Ein erster Versuch am 06.09.2026 setzte auch hier `flex-wrap: wrap`. + Ergebnis, gemessen: Bei 1280 px brauchte die Leiste 1235 px (Marke + 375 + Bedienelemente 844 + Abstand 16) und hatte 1200 -- also brach + sie in zwei Zeilen um, und zwar erst NACHDEM das Skript den + Umschalter und den Chat-Knopf eingehaengt hatte. Die ganze Seite + sprang dabei 52 px nach unten (CLS 0,94). + + Flexbox zieht Umbrechen dem Schrumpfen vor. Wer hier `wrap` erlaubt, + bekommt also eine zweite Zeile, obwohl der Schriftzug daneben + bereitwillig kuerzer wuerde -- und ein gekuerzter Schriftzug ist + allemal besser als eine Seite, die beim Laden springt. Umgebrochen + wird nur noch auf sehr schmalen Geraeten (siehe unten), wo es + wirklich keinen anderen Weg gibt. */ .kopfleiste { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 16px; @@ -282,7 +297,41 @@ body.start > main { position: relative; z-index: 1; } .zurueck-knopf:hover .zurueck-knopf__pfeil { transform: none; } } -.kopfleiste__rechts { display: flex; align-items: center; gap: 12px; flex: 0 1 auto; min-width: 0; } +/* UMBRECHEN IST DIE VORGABE, NICHT DIE AUSNAHME (06.09.2026). + + Hier stand `flex-wrap` erst ab 380 px -- und der Kommentar weiter + unten erzaehlt selbst, warum das schon einmal schiefging: "Die + Rechnung ging genau auf, solange rechts VIER Dinge standen. Mit der + Glocke sind es FUENF, und bei 320 px passte es nicht mehr." + + Am 06.09.2026 kam der Chat-Knopf dazu. SECHS Dinge, und die Schwelle + blieb bei 380. Ergebnis, von pruef-handy gemessen: Bei 412 px -- der + haeufigsten Android-Breite ueberhaupt -- brach nichts um, der + Umschalter wurde auf 50 px zusammengedrueckt, sein Knopf behielt + seine 104 px und lag damit quer ueber dem Chat-Knopf. Wer auf "Meine + Sicht" tippte, landete im Chat. Auf ZWOELF Seiten. + + Die Lehre ist nicht "380 auf 430 erhoehen" -- das waere derselbe + Fehler ein drittes Mal, nur mit einer anderen Zahl. Eine feste + Schwelle ist eine Rechnung, die jemand einmal aufgestellt hat und die + beim naechsten Knopf still falsch wird. + + `flex-wrap: wrap` OHNE Schwelle rechnet nicht, sondern misst: Es + bricht genau dann um, wenn der Platz nicht reicht, und sonst nie. Auf + dem Rechner aendert sich dadurch nichts. */ +.kopfleiste__rechts { + display: flex; align-items: center; gap: 12px; + /* NICHT SCHRUMPFEN -- die MARKE gibt nach, nicht die Bedienelemente. + Mit `flex: 0 1 auto` wurde dieser Kasten schmaler als sein Inhalt, + und der brach dann schon bei 1280 px in eine zweite Zeile um. Das + war nicht nur haesslich, es war ein Sprung: Die Leiste wuchs, kaum + dass das Skript den Umschalter eingehaengt hatte, und schob die + ganze Seite 52 px nach unten (gemessen von pruef-tempo-workspace, + CLS 1,0). Der Schriftzug daneben darf gekuerzt werden, ein Knopf + nicht. */ + flex: 0 0 auto; min-width: 0; + flex-wrap: wrap; row-gap: 6px; justify-content: flex-end; +} .kopfleiste__rechts > * { flex: 0 0 auto; } .wer { @@ -599,37 +648,40 @@ body.start > main { position: relative; z-index: 1; } Die ZUORDNUNG ist ebenfalls gerechnet. Das Auge vergleicht nur, was NEBENEINANDER liegt -- gesucht wurde deshalb die Anordnung mit dem größtmöglichen kleinsten Abstand zweier Nachbarn im Raster. Ergebnis: - mindestens 105,9 Grad zwischen allen Nachbarn. Zum Vergleich: Der + mindestens 120,0 Grad zwischen allen Nachbarn (06.09.2026 mit 21 Kacheln neu gerechnet). Zum Vergleich: Der erste Versuch mit frei gewählten Farben hatte Paare bei 1,4 Grad. Unter allen Anordnungen, die das schaffen, wurde die passendste genommen -- LIVE rot, Technik gelb, Reports grün, Personen rot-gold. Geprüft gegen genau diesen Hintergrund: Helligkeitsband, Farbstärke - und Kontrast bestehen für alle siebzehn. + und Kontrast bestehen für alle einundzwanzig. Die Töne hängen bewusst NICHT an .kachel, sondern nur am Merkmal data-ton: Dieselbe Nummer färbt die Kachel und die Hinweiszeile, die dorthin führt. Eine Farbe gehört hier zu einem Bereich, nicht zu einem Bauteil. */ -[data-ton="1"] { --ton: #3e95e7; } -[data-ton="2"] { --ton: #6e89eb; } -[data-ton="3"] { --ton: #917ee4; } -[data-ton="4"] { --ton: #ac74d3; } -[data-ton="5"] { --ton: #c26cbb; } -[data-ton="6"] { --ton: #d1679d; } -[data-ton="7"] { --ton: #da657c; } -[data-ton="8"] { --ton: #dc6958; } -[data-ton="9"] { --ton: #d67130; } -[data-ton="10"] { --ton: #c67f09; } -[data-ton="11"] { --ton: #b08c09; } -[data-ton="12"] { --ton: #97970e; } -[data-ton="13"] { --ton: #72a234; } -[data-ton="14"] { --ton: #3ea95e; } -[data-ton="15"] { --ton: #0ba985; } -[data-ton="16"] { --ton: #08a6a0; } -[data-ton="17"] { --ton: #0aa3b6; } -[data-ton="18"] { --ton: #0f9ecf; } /* Start-Check */ +[data-ton="1"] { --ton: #3e95e7; } /* Dashboard */ +[data-ton="2"] { --ton: #688beb; } /* Zahlen */ +[data-ton="3"] { --ton: #8881e7; } /* LIVE */ +[data-ton="4"] { --ton: #a178dc; } /* Chat */ +[data-ton="5"] { --ton: #b670ca; } /* Schutz */ +[data-ton="6"] { --ton: #c76ab3; } /* Community */ +[data-ton="7"] { --ton: #d36699; } /* Reports */ +[data-ton="8"] { --ton: #da657c; } /* Kalender */ +[data-ton="9"] { --ton: #dc685d; } /* Bibliothek */ +[data-ton="10"] { --ton: #d96e3c; } /* Mein Steckbrief */ +[data-ton="11"] { --ton: #d0780e; } /* Personen */ +[data-ton="12"] { --ton: #bd8501; } /* Content */ +[data-ton="13"] { --ton: #a98f0d; } /* Aufgaben */ +[data-ton="14"] { --ton: #929909; } /* Technik */ +[data-ton="15"] { --ton: #72a234; } /* Calls */ +[data-ton="16"] { --ton: #47a858; } /* Start-Check */ +[data-ton="17"] { --ton: #11aa7a; } /* Agentur */ +[data-ton="18"] { --ton: #0da795; } /* Automationen */ +[data-ton="19"] { --ton: #16a4a9; } /* Dateien */ +[data-ton="20"] { --ton: #01a2bd; } /* Creator-Profile */ +[data-ton="21"] { --ton: #119dd4; } /* Scout */ /* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat. Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der @@ -2781,9 +2833,16 @@ body.start #protokoll { dadurch um 46 px (Rechner) bis 117 px (Handy) aus dem Bild, und der Abmelden-Knopf war nicht mehr erreichbar. Gemeldet von pruef-workspace-seiten und pruef-handy, bevor es jemand gesehen hat. - Diese Regel steht spaeter in der Datei und gewinnt deshalb. */ - flex: 0 1 auto; - min-width: 0; + Diese Regel steht spaeter in der Datei und gewinnt deshalb. + + NACHTRAG 06.09.2026: Seit die Kopfleiste an JEDER Breite umbrechen + darf (siehe .kopfleiste__rechts oben), ist Schrumpfen der falsche + Ausweg geworden. Ein Umschalter, der auf 50 px zusammenfaellt, + schiebt seinen eigenen Knopf ueber den Nachbarn -- das ist kein + Ueberlauf weniger, sondern einer an unsichtbarer Stelle. Er + behaelt jetzt seine Breite und wandert stattdessen in die zweite + Zeile, wenn es eng wird. */ + flex: 0 0 auto; } /* DAS WORT "SICHT" IST WEG (06.09.2026, Wunsch Filipe: "das soll viel @@ -2949,7 +3008,7 @@ body[data-fremde-sicht="ja"]::after { zwischen Suche und Glocke um. */ .kopfleiste > .marke { flex: 1 0 100%; } .kopfleiste__rechts { - width: 100%; justify-content: flex-end; + width: 100%; /* AUCH DIE ZWEITE ZEILE DARF UMBRECHEN. Ohne das reichten die 294 Pixel weiterhin nicht: Umschalter (104) + Suche (47) + Glocke (37) + Plakette + Abmelden sind zusammen mehr. Die @@ -2959,10 +3018,8 @@ body[data-fremde-sicht="ja"]::after { falsch aussieht. */ flex-wrap: wrap; row-gap: 6px; } - /* Und der Umschalter behaelt seine Breite, statt gequetscht zu - werden: Sein Inhalt kann ohnehin nicht unter 104 px, also waere - jedes Schrumpfen nur ein Ueberlauf mit anderem Namen. */ - .sicht { flex: 0 0 auto; } + /* Der Umschalter behaelt seine Breite -- das gilt seit dem 06.09.2026 + an jeder Breite und steht deshalb oben bei .sicht selbst. */ } @media (forced-colors: active) { body[data-fremde-sicht="ja"]::after { border-color: Highlight; } diff --git a/workspace/assets/js/bereiche.js b/workspace/assets/js/bereiche.js index 8d388c0c..30d09b73 100644 --- a/workspace/assets/js/bereiche.js +++ b/workspace/assets/js/bereiche.js @@ -309,22 +309,22 @@ name: 'Täglich', unter: 'Was du sowieso jeden Tag aufmachst', bereiche: [ - { name: 'Dashboard', unter: 'Alle Creator auf einen Blick', zeichen: 'dashboard', ton: 3, + { name: 'Dashboard', unter: 'Alle Creator auf einen Blick', zeichen: 'dashboard', ton: 1, gross: true, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'uebersicht.html', szene: 'showbuehne' }, - { name: 'Aufgaben', unter: 'Offen, in Arbeit, Review', zeichen: 'aufgaben', ton: 11, + { name: 'Aufgaben', unter: 'Offen, in Arbeit, Review', zeichen: 'aufgaben', ton: 13, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'aufgaben.html', szene: 'garage' }, - { name: 'Kalender', unter: 'Termine und Calls', zeichen: 'kalender', ton: 16, + { name: 'Kalender', unter: 'Termine und Calls', zeichen: 'kalender', ton: 8, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html', szene: 'skyline' }, - { name: 'Calls & Protokolle', unter: 'Gespräche mit To-dos', zeichen: 'calls', ton: 9, + { name: 'Calls & Protokolle', unter: 'Gespräche mit To-dos', zeichen: 'calls', ton: 15, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html', szene: 'lounge' }, /* CHAT (06.09.2026). Steht neben "Calls & Protokolle", weil beides Gespraech ist -- das eine geplant und protokolliert, das andere zwischendurch. Ton 14 (Gruen), weil die taegliche Gruppe sonst 3, 11, 16, 9 und 2 traegt und sich der Chat davon abheben soll. */ - { name: 'Chat', unter: 'Nachrichten mit deinem Team', zeichen: 'chat', ton: 14, + { name: 'Chat', unter: 'Nachrichten mit deinem Team', zeichen: 'chat', ton: 4, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'chat.html', szene: 'lounge' }, - { name: 'Dateien', unter: 'Ablage und Freigaben', zeichen: 'dateien', ton: 2, + { name: 'Dateien', unter: 'Ablage und Freigaben', zeichen: 'dateien', ton: 19, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html', szene: 'arena' }, ], }, @@ -364,25 +364,25 @@ (Orangerot) darunter. Wer diese Gruppe umsortiert, lässt tools/kachel-farben.mjs neu laufen -- die Nachbarschaften haben sich durch diese Kachel geändert. */ - { name: 'Zahlen', unter: 'Diamanten, LIVE-Tage, Verweildauer', zeichen: 'zahlen', ton: 4, + { name: 'Zahlen', unter: 'Diamanten, LIVE-Tage, Verweildauer', zeichen: 'zahlen', ton: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'leistung.html', szene: 'arena' }, - { name: 'Mein Steckbrief', unter: 'Dein Bild und deine Kanäle', zeichen: 'steckbrief', ton: 15, + { name: 'Mein Steckbrief', unter: 'Dein Bild und deine Kanäle', zeichen: 'steckbrief', ton: 10, rollen: ['admin', 'manager', 'scout'], ziel: 'steckbrief.html', szene: 'halle' }, - { name: 'Creator-Profile', unter: 'Ziele und 90-Tage-Plan', zeichen: 'profil', ton: 8, + { name: 'Creator-Profile', unter: 'Ziele und 90-Tage-Plan', zeichen: 'profil', ton: 20, rollen: ['admin', 'manager', 'scout'], ziel: 'profil.html', szene: 'halle' }, - { name: 'Mein Profil', unter: 'Dein Steckbrief, Ziele und Pläne', zeichen: 'profil', ton: 8, + { name: 'Mein Profil', unter: 'Dein Steckbrief, Ziele und Pläne', zeichen: 'profil', ton: 20, rollen: ['creator'], ziel: 'profil.html', szene: 'halle' }, - { name: 'Start-Check', unter: 'Erstanalyse, 16 Punkte', zeichen: 'startcheck', ton: 18, + { name: 'Start-Check', unter: 'Erstanalyse, 16 Punkte', zeichen: 'startcheck', ton: 16, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'startcheck.html', szene: 'wald' }, - { name: 'LIVE-Analyse', unter: 'Vorher, während, danach', zeichen: 'live', ton: 7, + { name: 'LIVE-Analyse', unter: 'Vorher, während, danach', zeichen: 'live', ton: 3, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=live', szene: 'portal' }, - { name: 'Content-Ideen', unter: 'Idee bis Upload', zeichen: 'content', ton: 13, + { name: 'Content-Ideen', unter: 'Idee bis Upload', zeichen: 'content', ton: 12, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html', szene: 'portal' }, { name: 'Community', unter: 'Moderation und Konflikte', zeichen: 'community', ton: 6, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community', szene: 'lounge' }, - { name: 'Technik', unter: 'Setup, Fehler, Lösungen', zeichen: 'technik', ton: 12, + { name: 'Technik', unter: 'Setup, Fehler, Lösungen', zeichen: 'technik', ton: 14, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=technik', szene: 'garage' }, - { name: 'Schutz & Regeln', unter: 'Richtlinien und Vorfälle', zeichen: 'schutz', ton: 1, + { name: 'Schutz & Regeln', unter: 'Richtlinien und Vorfälle', zeichen: 'schutz', ton: 5, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=schutz', szene: 'halle' }, /* DER SECHSTE BEREICH (06.09.2026). Kernprinzip 04 des Konzepts lautet "Die Agentur bleibt angebunden" -- und dafuer gab es @@ -392,9 +392,13 @@ die andere Seite des Tisches die Agentur ist. Ton 9 (Orange) -- deutlich von seinen Nachbarn 1 (Blau) und 14 (Gruen) entfernt. */ - { name: 'Agentur', unter: 'Kampagnen, Schulungen, offizieller Weg', zeichen: 'agentur', ton: 9, + /* Untertitel gekuerzt (06.09.2026): "Kampagnen, Schulungen, + offizieller Weg" wurde auf dem Handy abgeschnitten -- gemessen + von pruef-start-ansicht. Ein Untertitel, der mitten im Wort + endet, ist schlechter als ein kuerzerer. */ + { name: 'Agentur', unter: 'Kampagnen und offizielle Wege', zeichen: 'agentur', ton: 17, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=agentur', szene: 'halle' }, - { name: 'Reports & Review', unter: 'Woche und Entscheidung', zeichen: 'reports', ton: 14, + { name: 'Reports & Review', unter: 'Woche und Entscheidung', zeichen: 'reports', ton: 7, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'report.html', szene: 'showbuehne' }, ], }, @@ -420,16 +424,16 @@ anderes sagt als die Software, ist schlimmer als beides einzeln: Man weiss danach nicht mehr, welcher von beiden man glauben soll. */ - { name: 'Personen & Zugänge', unter: 'Rollen und Codes', zeichen: 'personen', ton: 10, + { name: 'Personen & Zugänge', unter: 'Rollen und Codes', zeichen: 'personen', ton: 11, rollen: ['admin'], ziel: 'personen.html', szene: 'halle' }, - { name: 'Scout-Pipeline', unter: 'Entdeckt bis übergeben', zeichen: 'scouting', ton: 17, + { name: 'Scout-Pipeline', unter: 'Entdeckt bis übergeben', zeichen: 'scouting', ton: 21, rollen: ['admin', 'manager', 'scout'], ziel: 'scouting.html', szene: 'wald' }, - { name: 'Wissens-Bibliothek', unter: 'Anleitungen und PDFs', zeichen: 'wissen', ton: 4, + { name: 'Wissens-Bibliothek', unter: 'Anleitungen und PDFs', zeichen: 'wissen', ton: 9, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'wissen.html', szene: 'arena' }, /* Ebenfalls nur DogFather (02.09.2026). Was von allein passiert, legt fest, was ALLEN anderen automatisch zugeschoben wird -- das gehört in dieselbe Hand wie die Rollenvergabe. */ - { name: 'Automationen', unter: 'Was von allein passiert', zeichen: 'automation', ton: 5, + { name: 'Automationen', unter: 'Was von allein passiert', zeichen: 'automation', ton: 18, rollen: ['admin'], ziel: 'automation.html', szene: 'garage' }, ], }, diff --git a/workspace/assets/js/kalender-abo.js b/workspace/assets/js/kalender-abo.js new file mode 100644 index 00000000..ba8d8ced --- /dev/null +++ b/workspace/assets/js/kalender-abo.js @@ -0,0 +1,126 @@ +/* =================================================================== + kalender-abo.js — den eigenen Kalender abonnierbar machen. + + Eine eigene Datei und nicht ein Anhängsel in kalender.js: Der + Kalender selbst ist die größte Datei der Anwendung, und diese Sache + hier hat mit dem Zeichnen von Monaten nichts zu tun. Fällt sie aus, + steht der Kalender trotzdem. + + Der Link wird NICHT beim Öffnen der Seite angelegt. Ein Geheimnis, + das entsteht, ohne dass jemand es wollte, liegt danach herum -- und + niemand weiß, dass es existiert. Angelegt wird es beim Klick. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + if (!$('abo-block')) return; // Seite ohne diesen Block + + const melde = (t) => { $('abo-fehler').textContent = t || ''; }; + + async function hole(weg, einst) { + const a = await fetch(weg, { credentials: 'same-origin', ...einst }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); } + return a; + } + + function zeige(daten) { + const hat = !!daten?.link; + $('abo-hat').hidden = !hat; + $('abo-hilfe').hidden = !hat; + $('abo-erzeugen').hidden = hat; + $('abo-neu').hidden = !hat; + $('abo-aus').hidden = !hat; + if (hat) $('abo-link').value = daten.link; + + /* WANN ZULETZT ABGEHOLT -- die einzige Auskunft, die wirklich sagt, + ob die Sache funktioniert. "Link erzeugt" beweist nur, dass ein + Link da ist; erst der Abruf beweist, dass ein Kalender ihn auch + benutzt. Ohne diese Zeile sucht man bei "kommt nicht an" an der + falschen Stelle. */ + const stand = $('abo-stand'); + if (!hat) { stand.textContent = ''; return; } + if (!daten.zuletzt) { + stand.textContent = 'Noch nie abgerufen – sobald dein Kalender ihn holt, steht es hier.'; + stand.dataset.warten = 'ja'; + return; + } + delete stand.dataset.warten; + const d = new Date(daten.zuletzt); + const her = Math.round((Date.now() - d.getTime()) / 60000); + const wann = her < 2 ? 'gerade eben' + : her < 60 ? `vor ${her} Minuten` + : her < 60 * 48 ? `vor ${Math.round(her / 60)} Stunden` + : `am ${d.toLocaleDateString('de-DE')}`; + stand.textContent = `Dein Kalender hat die Termine zuletzt ${wann} abgeholt.`; + } + + async function laden() { + try { + const a = await hole('/workspace/api/ics'); + if (!a.ok) return; + zeige(await a.json()); + } catch { /* umgeleitet */ } + } + + async function erzeugen(erneuern) { + if (erneuern && !window.confirm( + 'Neuen Link erzeugen?\n\nDer alte hört im selben Moment auf zu funktionieren. ' + + 'Ein Kalender, der ihn abonniert hat, bekommt danach keine Termine mehr, ' + + 'bis du ihm den neuen gibst.')) return; + melde(''); + try { + const a = await hole('/workspace/api/ics', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + if (!a.ok) { melde('Ging gerade nicht. Bitte später noch einmal.'); return; } + const d = await a.json(); + zeige({ link: d.link, zuletzt: null }); + /* Nach dem Erzeugen die Anleitung aufklappen -- wer gerade einen + Link bekommen hat, will als Nächstes wissen, wohin damit. */ + $('abo-hilfe').hidden = false; + } catch { /* umgeleitet */ } + } + + async function abschalten() { + if (!window.confirm( + 'Abschalten?\n\nDeine Termine verschwinden dann aus jedem Kalender, ' + + 'der sie abonniert hat.')) return; + melde(''); + try { + const a = await hole('/workspace/api/ics', { method: 'DELETE' }); + if (!a.ok) { melde('Ging gerade nicht.'); return; } + zeige(null); + } catch { /* umgeleitet */ } + } + + async function kopieren() { + const feld = $('abo-link'); + const knopf = $('abo-kopieren'); + try { + await navigator.clipboard.writeText(feld.value); + } catch { + /* Ohne Zwischenablage-Recht (unsicherer Kontext, alter Browser) + wenigstens markieren -- dann reicht Strg+C. Wortlos scheitern + wäre das Schlechteste: Man klickt dreimal und weiß nicht, ob + etwas passiert ist. */ + feld.focus(); + feld.select(); + knopf.textContent = 'Jetzt Strg+C'; + setTimeout(() => { knopf.textContent = 'Kopieren'; }, 3000); + return; + } + knopf.textContent = 'Kopiert'; + setTimeout(() => { knopf.textContent = 'Kopieren'; }, 2000); + } + + $('abo-erzeugen').addEventListener('click', () => erzeugen(false)); + $('abo-neu').addEventListener('click', () => erzeugen(true)); + $('abo-aus').addEventListener('click', abschalten); + $('abo-kopieren').addEventListener('click', kopieren); + /* Erst laden, wenn jemand aufklappt -- die Seite hat beim Öffnen + Wichtigeres zu tun, und die meisten klappen nie auf. */ + $('abo-block').addEventListener('toggle', () => { + if ($('abo-block').open) laden(); + }, { once: true }); +})(); diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js index 9b4b46c7..f1288342 100644 --- a/workspace/assets/js/kopf.js +++ b/workspace/assets/js/kopf.js @@ -529,8 +529,23 @@ const strom = new EventSource('/workspace/api/chat/strom'); strom.addEventListener('neu', holen); /* Nach einem Abriss verbindet der Browser selbst neu -- dann - einmal nachzählen, was inzwischen kam. */ - strom.addEventListener('open', holen); + einmal nachzählen, was inzwischen kam. + + NUR AB DEM ZWEITEN MAL (06.09.2026). Hier stand schlicht + `strom.addEventListener('open', holen)` -- und damit fragte + JEDE Seite die Zahl zweimal ab: einmal direkt beim Laden und + einmal Millisekunden später, sobald der Strom aufging. + Gemessen von pruef-tempo-workspace auf fünf Seiten. + + Zwei Anfragen für dieselbe Zahl sind kein Fehler, den jemand + bemerkt -- und genau deshalb bleibt so etwas jahrelang + stehen. Bei zehn Angemeldeten ist es die doppelte Last für + nichts. */ + let schonOffen = false; + strom.addEventListener('open', () => { + if (schonOffen) holen(); + schonOffen = true; + }); } catch { /* dann eben nur beim Laden */ } } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 9a02910d..21e06117 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -9,9 +9,9 @@ - - - + + + @@ -211,10 +211,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 0e886e4b..183bec2a 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -134,10 +134,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index af14b1f2..5e903e40 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -117,14 +117,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 6ed3855b..456b518b 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -76,11 +76,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 58212668..a5d88b18 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -9,16 +9,16 @@ - - + + - - + + @@ -154,9 +154,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html index 9f1053f9..a9fb607d 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -166,12 +166,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index dd9fc315..7288b37a 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -95,10 +95,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index a912bf76..95daea1d 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -145,6 +145,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index b9b68faf..2637a650 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -168,6 +168,57 @@
+ +
+ + In deinen eigenen Kalender holen + Google, Apple, Outlook – einmal einrichten, dann läuft es + +
+

+ Du bekommst einen persönlichen Link. Wer ihn hat, sieht deine Termine – + gib ihn also nicht weiter. Falls doch: Du kannst ihn jederzeit ersetzen, + der alte ist dann sofort tot. +

+ + + +

+ +
+ + + +
+ + + + +
+
+