Der Handy-Durchgang: drei echte Bedienfehler, alle vom selben Ursprung

Filipe: "ich will dass du die komplette seite auf dem handy abcheckst.
ich will dass alles perfekt aussieht und bedienbar ist."

DER SCHWERSTE FUND: Auf ZWOELF Seiten war der "Meine Sicht"-Umschalter
nicht bedienbar -- wer darauf tippte, landete im Chat.

Bei 412 px (der haeufigsten Android-Breite ueberhaupt) brach die
Kopfleiste nicht um, der Umschalter wurde auf 50 px zusammengedrueckt,
sein Knopf behielt aber seine 104 px Mindestbreite und lag damit quer
ueber dem Chat-Knopf. Sichtbar war davon nichts.

Und die Ursache steht seit dem 05.09. woertlich im Kommentar daneben:
"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.
habe ich den Chat-Knopf dazugesetzt -- SECHS -- und die Schwelle bei 380
gelassen. Derselbe Fehler, eine Position weiter.

Die Lehre ist nicht "380 auf 430 erhoehen"; das waere er 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.

DASSELBE NOCH EINMAL, am anderen Ende: Bei 1280 px brauchte die Leiste
1235 px (Marke 375 + Bedienelemente 844 + Abstand 16) und hatte 1200.
Auch hier war der Chat-Knopf der Tropfen. Sie brach um, sobald das
Skript die Bedienelemente eingehaengt hatte, und schob die ganze Seite
52 px nach unten -- CLS 0,94. Jetzt gibt die MARKE nach (sie darf
gekuerzt werden, ein Knopf nicht), und umgebrochen wird nur noch auf
sehr schmalen Geraeten.

WEITERE ECHTE FUNDE:
  * /workspace/api/chat/ungelesen wurde auf JEDER Seite ZWEIMAL geholt:
    einmal beim Laden, einmal Millisekunden spaeter beim Aufgehen des
    Ereignisstroms. Der 'open'-Zuhoerer war fuer Wiederverbindungen
    gedacht und feuerte auch beim ersten Mal.
  * Drei Kacheln teilten sich einen Farbton mit einer anderen (18 Farben
    auf 21 Kacheln). Filipe wollte ausdruecklich, dass jede ihre eigene
    hat. Nicht eine Farbe dazuerfunden -- der ganze Farbkreis ist mit
    tools/kachel-farben.mjs neu in 21 geteilt; kleinster Abstand zweier
    Nachbarn jetzt 120 Grad (vorher 106). Dabei fiel eine feste 16 in
    der Mischschleife auf: Bei 18 Kacheln wurden die letzten beiden nie
    mitgemischt.
  * Der Agentur-Untertitel wurde auf dem Handy abgeschnitten.
  * Ein zugeklappter <details>-Kasten (Kalender-Abo) verdeckte einen
    Filter-Chip: Chromium versteckt dessen Inhalt ueber
    `content-visibility`, nicht ueber `display` -- die Kaesten behalten
    eine Groesse. Dieselbe Falle wie bei den Zeitbloecken, dieselbe
    Loesung.

UND DREI PRUEFUNGEN, DIE SELBST FALSCH LAGEN:
  * "achtzehn Kacheln" stand als feste Zahl im Test. Eine Pruefung, die
    bei jeder neuen Kachel rot wird, erzieht dazu, ihr Rotwerden zu
    ignorieren. Sie zaehlt jetzt aus bereiche.js.
  * "das Licht der Kalender-Kachel (tuerkis) muss mehr Blau als Rot
    haben" -- Wissen von aussen, und nach dem Neurechnen war der
    Kalender rosa. Gemessen wird jetzt gegen den Ton der Kachel selbst.
  * pruef-workspace-umzug meldete sein Ergebnis in eigenen Worten. Der
    Gesamtlauf las daraus NULL Pruefungen und schrieb bei JEDEM Lauf ein
    FEHL, obwohl alle 31 Punkte bestanden. Ein Fehlalarm, der immer
    kommt, macht den einen echten unsichtbar.

Und weil "BUTTON 'Meine Sicht' verdeckt" mich eine Stunde gekostet hat,
sagen pruef-handy und pruef-tempo jetzt DAZU, was verdeckt und was
springt -- mit Elternkette und Koordinaten. Ein Befund, den man nicht
verorten kann, ist ein halber.

NEU: der Kalender zum Abonnieren (Stufe 3.2). Persoenlicher, jederzeit
widerrufbarer Link fuer Google, Apple und Outlook. 36 Pruefungen, die
das ICS ZURUECKLESEN statt es anzusehen -- entfaltet, entschluesselt,
verglichen. Wichtigster Punkt: Der Weg ohne Anmeldung darf nichts
zeigen, was der Weg mit Anmeldung nicht zeigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 23:15:37 +02:00
co-authored by Claude Opus 5
parent bede91921d
commit 233cd76d78
34 changed files with 1707 additions and 258 deletions
+15
View File
@@ -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);
+58
View File
@@ -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/<schluessel>.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?
======================================================================= */
+9
View File
@@ -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 <details> 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;
};
+61 -2
View File
@@ -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 <details> 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);
+397
View File
@@ -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);
+80 -16
View File
@@ -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})`);
+28 -6
View File
@@ -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) {
+17 -2
View File
@@ -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); }
+440
View File
@@ -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" });
}
});
+36 -12
View File
@@ -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;
+92
View File
@@ -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 <details>
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; }
}
+86 -29
View File
@@ -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; }
+25 -21
View File
@@ -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' },
],
},
+126
View File
@@ -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 });
})();
+17 -2
View File
@@ -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 */ }
}
+8 -8
View File
@@ -9,9 +9,9 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
</head>
<body class="start">
@@ -211,10 +211,10 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/aufgaben.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/aufgaben.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609062315" />
</head>
<body class="start">
@@ -134,10 +134,10 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/automation.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/automation.js?v=202609062315" defer></script>
</body>
</html>
+13 -13
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609062315" />
</head>
<body class="start">
@@ -117,14 +117,14 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/checkliste.js?v=202609062206" defer></script>
<script src="assets/js/ampel.js?v=202609062206" defer></script>
<script src="assets/js/schulung.js?v=202609062206" defer></script>
<script src="assets/js/vorlagen.js?v=202609062206" defer></script>
<script src="assets/js/bereich.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/checkliste.js?v=202609062315" defer></script>
<script src="assets/js/ampel.js?v=202609062315" defer></script>
<script src="assets/js/schulung.js?v=202609062315" defer></script>
<script src="assets/js/vorlagen.js?v=202609062315" defer></script>
<script src="assets/js/bereich.js?v=202609062315" defer></script>
</body>
</html>
+10 -10
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609062315" />
</head>
<body class="start">
@@ -76,11 +76,11 @@
</main>
<script src="assets/js/ki.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/calls.js?v=202609062206" defer></script>
<script src="assets/js/ki.js?v=202609062315" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/calls.js?v=202609062315" defer></script>
</body>
</html>
+8 -8
View File
@@ -9,16 +9,16 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
Dialog zu sehen bekam. -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609062315" />
</head>
<body class="start">
@@ -154,9 +154,9 @@
</form>
</dialog>
<script src="assets/js/bereiche.js?v=202609062206"></script>
<script src="assets/js/kopf.js?v=202609062206"></script>
<script src="assets/js/glocke.js?v=202609062206"></script>
<script src="assets/js/chat.js?v=202609062206"></script>
<script src="assets/js/bereiche.js?v=202609062315"></script>
<script src="assets/js/kopf.js?v=202609062315"></script>
<script src="assets/js/glocke.js?v=202609062315"></script>
<script src="assets/js/chat.js?v=202609062315"></script>
</body>
</html>
+11 -11
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/content.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/content.css?v=202609062315" />
</head>
<body class="start">
@@ -166,12 +166,12 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/checkliste.js?v=202609062206" defer></script>
<script src="assets/js/vorlagen.js?v=202609062206" defer></script>
<script src="assets/js/content.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/checkliste.js?v=202609062315" defer></script>
<script src="assets/js/vorlagen.js?v=202609062315" defer></script>
<script src="assets/js/content.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609062315" />
</head>
<body class="start">
@@ -95,10 +95,10 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/dateien.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/dateien.js?v=202609062315" defer></script>
</body>
</html>
+2 -2
View File
@@ -27,7 +27,7 @@
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
</head>
<body class="gate">
@@ -145,6 +145,6 @@
</p>
</main>
<script src="assets/js/gate.js?v=202609062206" defer></script>
<script src="assets/js/gate.js?v=202609062315" defer></script>
</body>
</html>
+61 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609062315" />
</head>
<body class="start">
@@ -168,6 +168,57 @@
<div class="k-serienliste" id="serien"></div>
</section>
<!-- KALENDER ABONNIEREN (06.09.2026, Stufe 3.2 des Plans).
Steht unten und zugeklappt: Man richtet ihn EINMAL ein und sieht
ihn danach nie wieder an. Ein Kasten, der dauerhaft Platz nimmt,
obwohl er einmal im Leben gebraucht wird, drängt sich vor die
Termine – und um die geht es hier.
Warum es das überhaupt gibt: Der Workspace kann benachrichtigen,
aber er kann nicht verhindern, dass jemand einen Zahnarzttermin
auf denselben Dienstag legt. Das kann nur ein Kalender, der die
Termine dort hat, wo ohnehin nachgesehen wird. -->
<details class="k-abo" id="abo-block">
<summary class="k-abo__kopf">
<span class="k-abo__titel">In deinen eigenen Kalender holen</span>
<span class="k-abo__unter">Google, Apple, Outlook – einmal einrichten, dann läuft es</span>
</summary>
<div class="k-abo__inhalt">
<p class="k-abo__satz" id="abo-satz">
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.
</p>
<div class="k-abo__zeile" id="abo-hat" hidden>
<input class="k-abo__feld" id="abo-link" readonly aria-label="Dein Kalender-Link" />
<button type="button" class="knopf knopf--klein" id="abo-kopieren">Kopieren</button>
</div>
<p class="k-abo__stand" id="abo-stand"></p>
<div class="k-abo__knoepfe">
<button type="button" class="knopf knopf--klein" id="abo-erzeugen">Link erzeugen</button>
<button type="button" class="abmelden" id="abo-neu" hidden>Neuen Link erzeugen</button>
<button type="button" class="abmelden abmelden--warnung" id="abo-aus" hidden>Abschalten</button>
</div>
<p class="k-abo__hilfe" id="abo-hilfe" hidden>
<strong>So trägst du ihn ein:</strong>
<span class="k-abo__weg">Google Kalender: links unten bei „Weitere Kalender“ auf das Plus,
dann „Per URL“ – Link einfügen – „Kalender hinzufügen“.</span>
<span class="k-abo__weg">iPhone: Einstellungen → Apps → Kalender → Accounts →
Account hinzufügen → Andere → Kalenderabo hinzufügen – Link einfügen.</span>
<span class="k-abo__weg">Outlook: Kalender → Kalender hinzufügen → Aus dem Internet abonnieren.</span>
<span class="k-abo__weg leise">Die Kalender holen neue Termine selbst ab – meist innerhalb
weniger Stunden, nicht sofort. Das legen Google und Apple fest, nicht wir.</span>
</p>
<p class="fehler" id="abo-fehler" role="alert" aria-live="polite"></p>
</div>
</details>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<!-- Vier Ansichten auf DENSELBEN Daten. Der Server liefert einen
@@ -206,10 +257,11 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/kalender.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/kalender.js?v=202609062315" defer></script>
<script src="assets/js/kalender-abo.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609062315" />
</head>
<body class="start">
@@ -199,10 +199,10 @@
</form>
</dialog>
<script src="assets/js/wahl.js?v=202609062206"></script>
<script src="assets/js/bereiche.js?v=202609062206"></script>
<script src="assets/js/kopf.js?v=202609062206"></script>
<script src="assets/js/glocke.js?v=202609062206"></script>
<script src="assets/js/leistung.js?v=202609062206"></script>
<script src="assets/js/wahl.js?v=202609062315"></script>
<script src="assets/js/bereiche.js?v=202609062315"></script>
<script src="assets/js/kopf.js?v=202609062315"></script>
<script src="assets/js/glocke.js?v=202609062315"></script>
<script src="assets/js/leistung.js?v=202609062315"></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609062315" />
</head>
<body class="start">
@@ -123,10 +123,10 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/personen.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/personen.js?v=202609062315" defer></script>
</body>
</html>
+10 -10
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609062315" />
</head>
<body class="start">
@@ -201,11 +201,11 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/steckbrief.js?v=202609062206" defer></script>
<script src="assets/js/profil.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/steckbrief.js?v=202609062315" defer></script>
<script src="assets/js/profil.js?v=202609062315" defer></script>
</body>
</html>
+10 -10
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/report.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/report.css?v=202609062315" />
</head>
<body class="start">
@@ -93,11 +93,11 @@
</main>
<script src="assets/js/ki.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/report.js?v=202609062206" defer></script>
<script src="assets/js/ki.js?v=202609062315" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/report.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609062315" />
</head>
<body class="start">
@@ -126,10 +126,10 @@
</div>
</div>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/scouting.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/scouting.js?v=202609062315" defer></script>
</body>
</html>
+7 -7
View File
@@ -9,8 +9,8 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
</head>
<body class="start">
@@ -160,10 +160,10 @@
</p>
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/start.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/start.js?v=202609062315" defer></script>
</body>
</html>
+10 -10
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609062315" />
</head>
<body class="start">
@@ -72,11 +72,11 @@
</main>
<script src="assets/js/ki.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/startcheck.js?v=202609062206" defer></script>
<script src="assets/js/ki.js?v=202609062315" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/startcheck.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609062315" />
</head>
<body class="start">
@@ -113,10 +113,10 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/steckbrief.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/steckbrief.js?v=202609062315" defer></script>
</body>
</html>
+7 -7
View File
@@ -9,9 +9,9 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609062315" />
</head>
<body class="start">
@@ -82,10 +82,10 @@
</main>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/uebersicht.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/uebersicht.js?v=202609062315" defer></script>
</body>
</html>
+9 -9
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#0674b9" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609062206" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609062315" />
</head>
<body class="start">
@@ -177,10 +177,10 @@
</main>
<script src="assets/js/wahl.js?v=202609062206" defer></script>
<script src="assets/js/bereiche.js?v=202609062206" defer></script>
<script src="assets/js/kopf.js?v=202609062206" defer></script>
<script src="assets/js/glocke.js?v=202609062206" defer></script>
<script src="assets/js/wissen.js?v=202609062206" defer></script>
<script src="assets/js/wahl.js?v=202609062315" defer></script>
<script src="assets/js/bereiche.js?v=202609062315" defer></script>
<script src="assets/js/kopf.js?v=202609062315" defer></script>
<script src="assets/js/glocke.js?v=202609062315" defer></script>
<script src="assets/js/wissen.js?v=202609062315" defer></script>
</body>
</html>