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:
@@ -41,6 +41,7 @@ import { pushRouter, pushStarten } from "./workspace-push.js";
|
|||||||
import { chatRouter } from "./workspace-chat.js";
|
import { chatRouter } from "./workspace-chat.js";
|
||||||
import { leistungRouter } from "./workspace-leistung.js";
|
import { leistungRouter } from "./workspace-leistung.js";
|
||||||
import { fruehwarnungRouter } from "./workspace-fruehwarnung.js";
|
import { fruehwarnungRouter } from "./workspace-fruehwarnung.js";
|
||||||
|
import { icsRouter } from "./workspace-ics.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const SITE_DIR = join(__dirname, "..");
|
const SITE_DIR = join(__dirname, "..");
|
||||||
@@ -186,6 +187,20 @@ app.use(chatRouter);
|
|||||||
melden, deshalb stehen sie beieinander. */
|
melden, deshalb stehen sie beieinander. */
|
||||||
app.use(leistungRouter);
|
app.use(leistungRouter);
|
||||||
app.use(fruehwarnungRouter);
|
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(vorlagenRouter);
|
||||||
app.use(schulungRouter);
|
app.use(schulungRouter);
|
||||||
app.use(ampelRouter);
|
app.use(ampelRouter);
|
||||||
|
|||||||
@@ -413,6 +413,64 @@ ok(ohneSchranke.length === 0,
|
|||||||
? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ")
|
? `OHNE HERKUNFTSPRUEFUNG:\n ` + ohneSchranke.slice(0, 10).join("\n ")
|
||||||
: "kein schreibender Weg nimmt eine fremde Herkunft an");
|
: "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?
|
5. Hat irgendein Weg gar nicht geantwortet?
|
||||||
======================================================================= */
|
======================================================================= */
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ const messen = () => {
|
|||||||
const sichtbar = (e) => {
|
const sichtbar = (e) => {
|
||||||
const r = e.getBoundingClientRect();
|
const r = e.getBoundingClientRect();
|
||||||
if (r.width <= 0 || r.height <= 0) return false;
|
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);
|
const st = getComputedStyle(e);
|
||||||
return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
|
return st.visibility !== "hidden" && st.display !== "none" && Number(st.opacity) > 0.05;
|
||||||
};
|
};
|
||||||
|
|||||||
+61
-2
@@ -241,12 +241,70 @@ const messen = () => ({
|
|||||||
if (r.top < 0 || r.bottom > window.innerHeight) return false;
|
if (r.top < 0 || r.bottom > window.innerHeight) return false;
|
||||||
const st = getComputedStyle(e);
|
const st = getComputedStyle(e);
|
||||||
if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false;
|
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);
|
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
return oben && oben !== e && !e.contains(oben) && !oben.contains(e);
|
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),
|
.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
|
/* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die
|
||||||
einzige Stelle, ueber die man wieder wegkommt. */
|
einzige Stelle, ueber die man wieder wegkommt. */
|
||||||
kopfDrin: (() => {
|
kopfDrin: (() => {
|
||||||
@@ -322,7 +380,8 @@ for (const [breite, hoehe, wie] of GERAETE) {
|
|||||||
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")
|
+ (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")
|
||||||
+ (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "")
|
+ (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "")
|
||||||
+ (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "")
|
+ (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++;
|
if (!gut) fehler++;
|
||||||
|
|
||||||
seite.off("console", auf);
|
seite.off("console", auf);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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(" · "));
|
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})`);
|
ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`);
|
||||||
/* Achtzehn seit dem 01.09.2026: Der eigene Steckbrief hat eine eigene
|
/* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js
|
||||||
Kachel bekommen, weil er der PERSON gehoert und nicht der
|
gezaehlt (06.09.2026).
|
||||||
Creator-Akte. Die Farbtoene wurden dafuer neu gerechnet (18 statt
|
|
||||||
17 Winkel), nicht eine Farbe dazuerfunden -- der kleinste Abstand
|
Hier stand `=== 18`, mit einem Kommentar, der erklaerte, warum es
|
||||||
zweier Nachbarn liegt weiterhin bei 100 Grad. */
|
genau achtzehn sind. Am 06.09. kamen Chat, Zahlen und Agentur dazu
|
||||||
ok(r.kacheln.length === 18, `achtzehn Kacheln (${r.kacheln.length})`);
|
-- 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$/
|
ok(/^(Guten Morgen|Guten Mittag|Guten Tag|Guten Abend|Späten Abend|Noch wach), Filipe$/
|
||||||
.test(r.gruss || ""), `Begrüßung nach Tageszeit ("${r.gruss}")`);
|
.test(r.gruss || ""), `Begrüßung nach Tageszeit ("${r.gruss}")`);
|
||||||
/* Der Name muss eigenständig ausgezeichnet sein -- er ist das Wichtigste
|
/* 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,
|
ok(lichter.size === farben.length,
|
||||||
`und das Licht ist je Kachel ein anderes (${lichter.size} von ${farben.length})`);
|
`und das Licht ist je Kachel ein anderes (${lichter.size} von ${farben.length})`);
|
||||||
|
|
||||||
/* Zusaetzlich an echten Bildpunkten: Das Licht der Aufgaben-Kachel
|
/* Zusaetzlich an echten Bildpunkten -- das kann keine Zeichenkette
|
||||||
(orange) muss mehr Rot als Blau haben, das der Kalender-Kachel
|
vortaeuschen.
|
||||||
(tuerkis) mehr Blau als Rot. Das kann keine Zeichenkette
|
|
||||||
vortaeuschen. */
|
GEMESSEN GEGEN DIE EIGENE FARBE DER KACHEL, nicht gegen eine
|
||||||
for (const [name, pruefung] of [["Aufgaben", (p) => p[0] > p[2]],
|
Farbe aus dem Gedaechtnis (06.09.2026). Hier stand vorher: "das
|
||||||
["Kalender", (p) => p[2] > p[0]]]) {
|
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 b = await seite.evaluate((n) => {
|
||||||
const k = [...document.querySelectorAll(".kachel")]
|
const k = [...document.querySelectorAll(".kachel")]
|
||||||
.find((x) => x.querySelector(".kachel__name")?.textContent === n);
|
.find((x) => x.querySelector(".kachel__name")?.textContent === n);
|
||||||
if (!k) return null;
|
if (!k) return null;
|
||||||
const r = k.getBoundingClientRect();
|
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);
|
}, 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.mouse.move(b.x, b.y);
|
||||||
await seite.waitForTimeout(350);
|
await seite.waitForTimeout(350);
|
||||||
const bild = await seite.screenshot({ type: "png" });
|
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;
|
const s = i.width / window.innerWidth;
|
||||||
return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data];
|
return [...g.getImageData(Math.round(x * s), Math.round(y * s), 1, 1).data];
|
||||||
}, { b64: bild.toString("base64"), x: b.x, y: b.y });
|
}, { b64: bild.toString("base64"), x: b.x, y: b.y });
|
||||||
ok(pruefung(p), `das Licht auf "${name}" hat die richtige Farbe `
|
/* Die REIHENFOLGE der Kanaele muss uebereinstimmen. Absolute
|
||||||
+ `(rot ${p[0]}, grün ${p[1]}, blau ${p[2]})`);
|
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);
|
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);
|
await seite.waitForTimeout(300);
|
||||||
}
|
}
|
||||||
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
|
ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`);
|
||||||
|
|||||||
@@ -270,21 +270,43 @@ for (const datei of SEITEN) {
|
|||||||
await seite.waitForTimeout(400);
|
await seite.waitForTimeout(400);
|
||||||
const zweitens = await seite.evaluate(() => new Promise((fertig) => {
|
const zweitens = await seite.evaluate(() => new Promise((fertig) => {
|
||||||
let summe = 0;
|
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 {
|
try {
|
||||||
const b = new PerformanceObserver((liste) => {
|
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 });
|
b.observe({ type: "layout-shift", buffered: true });
|
||||||
setTimeout(() => { b.disconnect(); fertig(Math.round(summe * 1000) / 1000); }, 900);
|
setTimeout(() => {
|
||||||
} catch { fertig(0); }
|
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) {
|
if (zweitens.wert > GRENZE_SPRUNG) {
|
||||||
springende.push(`${datei} ${sprung} und erneut ${zweitens}`);
|
springende.push(`${datei} ${sprung} und erneut ${zweitens.wert}`
|
||||||
|
+ (zweitens.wer.length ? `
|
||||||
|
es springt: ${zweitens.wer.join(" | ")}` : ""));
|
||||||
} else {
|
} else {
|
||||||
/* Nicht verschweigen: Der Ausschlag war da, er hat sich nur nicht
|
/* Nicht verschweigen: Der Ausschlag war da, er hat sich nur nicht
|
||||||
bestaetigt. Wer die Zahl haeufig sieht, weiss, dass die Seite
|
bestaetigt. Wer die Zahl haeufig sieht, weiss, dass die Seite
|
||||||
nahe an der Grenze liegt. */
|
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) {
|
if (anfragen > GRENZE_ANFRAGEN || bytes > GRENZE_BYTES) {
|
||||||
|
|||||||
@@ -118,8 +118,23 @@ geprueft++;
|
|||||||
if (!zielStimmt) fehler.push(`Ziel ist "${NEUE_ADRESSE}", erwartet https://workspace.dogfather-universe.com`);
|
if (!zielStimmt) fehler.push(`Ziel ist "${NEUE_ADRESSE}", erwartet https://workspace.dogfather-universe.com`);
|
||||||
|
|
||||||
const SOLL = FAELLE.length + 11;
|
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);
|
for (const f of fehler) console.log(" FEHL " + f);
|
||||||
|
|
||||||
if (geprueft !== SOLL) { console.log("FEHL: Anzahl stimmt nicht."); process.exit(1); }
|
if (geprueft !== SOLL) { console.log("FEHL: Anzahl stimmt nicht."); process.exit(1); }
|
||||||
|
|||||||
@@ -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
@@ -52,8 +52,17 @@ const hex = (rgb) => "#" + rgb.map((v) =>
|
|||||||
/* 01.09.2026: 18 statt 17. Der eigene Steckbrief hat eine eigene
|
/* 01.09.2026: 18 statt 17. Der eigene Steckbrief hat eine eigene
|
||||||
Kachel bekommen (vorher teilte er sich die Seite mit der
|
Kachel bekommen (vorher teilte er sich die Seite mit der
|
||||||
Creator-Akte). Damit aendert sich der Abstand zwischen allen Toenen
|
Creator-Akte). Damit aendert sich der Abstand zwischen allen Toenen
|
||||||
-- deshalb wird hier neu gerechnet und nicht eine Farbe dazuerfunden. */
|
-- deshalb wird hier neu gerechnet und nicht eine Farbe dazuerfunden.
|
||||||
const ANZAHL = 18;
|
|
||||||
|
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;
|
const winkel = (i) => (i * 360 / ANZAHL + 250) % 360;
|
||||||
function farbe(i) {
|
function farbe(i) {
|
||||||
let C = C_MAX;
|
let C = C_MAX;
|
||||||
@@ -62,9 +71,9 @@ function farbe(i) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KACHELN = [
|
const KACHELN = [
|
||||||
"Dashboard", "Aufgaben", "Kalender", "Calls", "Dateien",
|
"Dashboard", "Aufgaben", "Kalender", "Calls", "Chat", "Dateien",
|
||||||
"Mein Steckbrief", "Creator-Profile", "Start-Check", "LIVE", "Content", "Community",
|
"Zahlen", "Mein Steckbrief", "Creator-Profile", "Start-Check", "LIVE", "Content",
|
||||||
"Technik", "Schutz", "Reports",
|
"Community", "Technik", "Schutz", "Agentur", "Reports",
|
||||||
"Personen", "Scout", "Bibliothek", "Automationen",
|
"Personen", "Scout", "Bibliothek", "Automationen",
|
||||||
];
|
];
|
||||||
const N = (n) => KACHELN.indexOf(n);
|
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,
|
/* Nachbarschaft aus dem tatsaechlichen Aufbau der Seite: drei Spalten,
|
||||||
Dashboard ueber zwei. Nebeneinander UND untereinander. */
|
Dashboard ueber zwei. Nebeneinander UND untereinander. */
|
||||||
const NACHBARN = [
|
const NACHBARN = [
|
||||||
|
/* Taeglich: Dashboard ueber zwei Spalten, dann Aufgaben daneben;
|
||||||
|
darunter Kalender, Calls, Chat; darunter Dateien. */
|
||||||
["Dashboard", "Aufgaben"], ["Dashboard", "Kalender"], ["Dashboard", "Calls"],
|
["Dashboard", "Aufgaben"], ["Dashboard", "Kalender"], ["Dashboard", "Calls"],
|
||||||
["Aufgaben", "Dateien"], ["Kalender", "Calls"], ["Calls", "Dateien"],
|
["Aufgaben", "Chat"], ["Kalender", "Calls"], ["Calls", "Chat"],
|
||||||
["Mein Steckbrief", "Creator-Profile"], ["Creator-Profile", "Start-Check"],
|
["Kalender", "Dateien"],
|
||||||
["Mein Steckbrief", "LIVE"], ["Start-Check", "LIVE"], ["Start-Check", "Content"],
|
/* Rund um den Creator: elf Kacheln in drei Spalten. */
|
||||||
["Content", "Community"], ["Community", "Technik"], ["Schutz", "Reports"],
|
["Zahlen", "Mein Steckbrief"], ["Mein Steckbrief", "Creator-Profile"],
|
||||||
["Creator-Profile", "Content"], ["Start-Check", "Community"], ["LIVE", "Technik"],
|
["Zahlen", "Start-Check"], ["Mein Steckbrief", "LIVE"], ["Creator-Profile", "Content"],
|
||||||
["Content", "Schutz"], ["Community", "Reports"],
|
["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"],
|
["Personen", "Scout"], ["Scout", "Bibliothek"], ["Personen", "Automationen"],
|
||||||
].map(([a, b]) => [N(a), N(b)]);
|
].map(([a, b]) => [N(a), N(b)]);
|
||||||
|
|
||||||
@@ -91,6 +106,10 @@ const WUNSCH = {
|
|||||||
LIVE: 17, Content: 38, Technik: 81, Reports: 144, Personen: 59,
|
LIVE: 17, Content: 38, Technik: 81, Reports: 144, Personen: 59,
|
||||||
Schutz: 250, Community: 335, Dashboard: 250, Kalender: 208,
|
Schutz: 250, Community: 335, Dashboard: 250, Kalender: 208,
|
||||||
Bibliothek: 271, "Start-Check": 165,
|
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) {
|
function wunschNaehe(z) {
|
||||||
let punkte = 0;
|
let punkte = 0;
|
||||||
@@ -119,7 +138,12 @@ const zufall = () => (saat = (saat * 1103515245 + 12345) % 2147483648) / 2147483
|
|||||||
let beste = null, besteNote = -1;
|
let beste = null, besteNote = -1;
|
||||||
for (let lauf = 0; lauf < 900; lauf++) {
|
for (let lauf = 0; lauf < 900; lauf++) {
|
||||||
const z = [...Array(ANZAHL).keys()];
|
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;
|
let note = bewerte(z), besser = true;
|
||||||
while (besser) {
|
while (besser) {
|
||||||
besser = false;
|
besser = false;
|
||||||
|
|||||||
@@ -802,3 +802,95 @@
|
|||||||
.k-dabei__zeichen { border: 1px solid CanvasText; }
|
.k-dabei__zeichen { border: 1px solid CanvasText; }
|
||||||
.k-dabei__haken path { stroke: 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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ body.start > main { position: relative; z-index: 1; }
|
|||||||
|
|
||||||
/* ---------- Kopfleiste ------------------------------------------------ */
|
/* ---------- 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 {
|
.kopfleiste {
|
||||||
position: sticky; top: 0; z-index: 5;
|
position: sticky; top: 0; z-index: 5;
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
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; }
|
.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; }
|
.kopfleiste__rechts > * { flex: 0 0 auto; }
|
||||||
|
|
||||||
.wer {
|
.wer {
|
||||||
@@ -599,37 +648,40 @@ body.start > main { position: relative; z-index: 1; }
|
|||||||
Die ZUORDNUNG ist ebenfalls gerechnet. Das Auge vergleicht nur, was
|
Die ZUORDNUNG ist ebenfalls gerechnet. Das Auge vergleicht nur, was
|
||||||
NEBENEINANDER liegt -- gesucht wurde deshalb die Anordnung mit dem
|
NEBENEINANDER liegt -- gesucht wurde deshalb die Anordnung mit dem
|
||||||
größtmöglichen kleinsten Abstand zweier Nachbarn im Raster. Ergebnis:
|
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.
|
erste Versuch mit frei gewählten Farben hatte Paare bei 1,4 Grad.
|
||||||
|
|
||||||
Unter allen Anordnungen, die das schaffen, wurde die passendste
|
Unter allen Anordnungen, die das schaffen, wurde die passendste
|
||||||
genommen -- LIVE rot, Technik gelb, Reports grün, Personen rot-gold.
|
genommen -- LIVE rot, Technik gelb, Reports grün, Personen rot-gold.
|
||||||
|
|
||||||
Geprüft gegen genau diesen Hintergrund: Helligkeitsband, Farbstärke
|
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
|
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
|
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
|
dorthin führt. Eine Farbe gehört hier zu einem Bereich, nicht zu einem
|
||||||
Bauteil. */
|
Bauteil. */
|
||||||
[data-ton="1"] { --ton: #3e95e7; }
|
[data-ton="1"] { --ton: #3e95e7; } /* Dashboard */
|
||||||
[data-ton="2"] { --ton: #6e89eb; }
|
[data-ton="2"] { --ton: #688beb; } /* Zahlen */
|
||||||
[data-ton="3"] { --ton: #917ee4; }
|
[data-ton="3"] { --ton: #8881e7; } /* LIVE */
|
||||||
[data-ton="4"] { --ton: #ac74d3; }
|
[data-ton="4"] { --ton: #a178dc; } /* Chat */
|
||||||
[data-ton="5"] { --ton: #c26cbb; }
|
[data-ton="5"] { --ton: #b670ca; } /* Schutz */
|
||||||
[data-ton="6"] { --ton: #d1679d; }
|
[data-ton="6"] { --ton: #c76ab3; } /* Community */
|
||||||
[data-ton="7"] { --ton: #da657c; }
|
[data-ton="7"] { --ton: #d36699; } /* Reports */
|
||||||
[data-ton="8"] { --ton: #dc6958; }
|
[data-ton="8"] { --ton: #da657c; } /* Kalender */
|
||||||
[data-ton="9"] { --ton: #d67130; }
|
[data-ton="9"] { --ton: #dc685d; } /* Bibliothek */
|
||||||
[data-ton="10"] { --ton: #c67f09; }
|
[data-ton="10"] { --ton: #d96e3c; } /* Mein Steckbrief */
|
||||||
[data-ton="11"] { --ton: #b08c09; }
|
[data-ton="11"] { --ton: #d0780e; } /* Personen */
|
||||||
[data-ton="12"] { --ton: #97970e; }
|
[data-ton="12"] { --ton: #bd8501; } /* Content */
|
||||||
[data-ton="13"] { --ton: #72a234; }
|
[data-ton="13"] { --ton: #a98f0d; } /* Aufgaben */
|
||||||
[data-ton="14"] { --ton: #3ea95e; }
|
[data-ton="14"] { --ton: #929909; } /* Technik */
|
||||||
[data-ton="15"] { --ton: #0ba985; }
|
[data-ton="15"] { --ton: #72a234; } /* Calls */
|
||||||
[data-ton="16"] { --ton: #08a6a0; }
|
[data-ton="16"] { --ton: #47a858; } /* Start-Check */
|
||||||
[data-ton="17"] { --ton: #0aa3b6; }
|
[data-ton="17"] { --ton: #11aa7a; } /* Agentur */
|
||||||
[data-ton="18"] { --ton: #0f9ecf; } /* Start-Check */
|
[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.
|
/* 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
|
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
|
dadurch um 46 px (Rechner) bis 117 px (Handy) aus dem Bild, und der
|
||||||
Abmelden-Knopf war nicht mehr erreichbar. Gemeldet von
|
Abmelden-Knopf war nicht mehr erreichbar. Gemeldet von
|
||||||
pruef-workspace-seiten und pruef-handy, bevor es jemand gesehen hat.
|
pruef-workspace-seiten und pruef-handy, bevor es jemand gesehen hat.
|
||||||
Diese Regel steht spaeter in der Datei und gewinnt deshalb. */
|
Diese Regel steht spaeter in der Datei und gewinnt deshalb.
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 0;
|
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
|
/* 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. */
|
zwischen Suche und Glocke um. */
|
||||||
.kopfleiste > .marke { flex: 1 0 100%; }
|
.kopfleiste > .marke { flex: 1 0 100%; }
|
||||||
.kopfleiste__rechts {
|
.kopfleiste__rechts {
|
||||||
width: 100%; justify-content: flex-end;
|
width: 100%;
|
||||||
/* AUCH DIE ZWEITE ZEILE DARF UMBRECHEN. Ohne das reichten die
|
/* AUCH DIE ZWEITE ZEILE DARF UMBRECHEN. Ohne das reichten die
|
||||||
294 Pixel weiterhin nicht: Umschalter (104) + Suche (47) +
|
294 Pixel weiterhin nicht: Umschalter (104) + Suche (47) +
|
||||||
Glocke (37) + Plakette + Abmelden sind zusammen mehr. Die
|
Glocke (37) + Plakette + Abmelden sind zusammen mehr. Die
|
||||||
@@ -2959,10 +3018,8 @@ body[data-fremde-sicht="ja"]::after {
|
|||||||
falsch aussieht. */
|
falsch aussieht. */
|
||||||
flex-wrap: wrap; row-gap: 6px;
|
flex-wrap: wrap; row-gap: 6px;
|
||||||
}
|
}
|
||||||
/* Und der Umschalter behaelt seine Breite, statt gequetscht zu
|
/* Der Umschalter behaelt seine Breite -- das gilt seit dem 06.09.2026
|
||||||
werden: Sein Inhalt kann ohnehin nicht unter 104 px, also waere
|
an jeder Breite und steht deshalb oben bei .sicht selbst. */
|
||||||
jedes Schrumpfen nur ein Ueberlauf mit anderem Namen. */
|
|
||||||
.sicht { flex: 0 0 auto; }
|
|
||||||
}
|
}
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
body[data-fremde-sicht="ja"]::after { border-color: Highlight; }
|
body[data-fremde-sicht="ja"]::after { border-color: Highlight; }
|
||||||
|
|||||||
@@ -309,22 +309,22 @@
|
|||||||
name: 'Täglich',
|
name: 'Täglich',
|
||||||
unter: 'Was du sowieso jeden Tag aufmachst',
|
unter: 'Was du sowieso jeden Tag aufmachst',
|
||||||
bereiche: [
|
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' },
|
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' },
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html', szene: 'lounge' },
|
||||||
/* CHAT (06.09.2026). Steht neben "Calls & Protokolle", weil
|
/* CHAT (06.09.2026). Steht neben "Calls & Protokolle", weil
|
||||||
beides Gespraech ist -- das eine geplant und protokolliert,
|
beides Gespraech ist -- das eine geplant und protokolliert,
|
||||||
das andere zwischendurch. Ton 14 (Gruen), weil die taegliche
|
das andere zwischendurch. Ton 14 (Gruen), weil die taegliche
|
||||||
Gruppe sonst 3, 11, 16, 9 und 2 traegt und sich der Chat
|
Gruppe sonst 3, 11, 16, 9 und 2 traegt und sich der Chat
|
||||||
davon abheben soll. */
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html', szene: 'arena' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -364,25 +364,25 @@
|
|||||||
(Orangerot) darunter. Wer diese Gruppe umsortiert, lässt
|
(Orangerot) darunter. Wer diese Gruppe umsortiert, lässt
|
||||||
tools/kachel-farben.mjs neu laufen -- die Nachbarschaften
|
tools/kachel-farben.mjs neu laufen -- die Nachbarschaften
|
||||||
haben sich durch diese Kachel geändert. */
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'content.html', szene: 'portal' },
|
||||||
{ name: 'Community', unter: 'Moderation und Konflikte', zeichen: 'community', ton: 6,
|
{ name: 'Community', unter: 'Moderation und Konflikte', zeichen: 'community', ton: 6,
|
||||||
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=community', szene: 'lounge' },
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'bereich.html?b=schutz', szene: 'halle' },
|
||||||
/* DER SECHSTE BEREICH (06.09.2026). Kernprinzip 04 des Konzepts
|
/* DER SECHSTE BEREICH (06.09.2026). Kernprinzip 04 des Konzepts
|
||||||
lautet "Die Agentur bleibt angebunden" -- und dafuer gab es
|
lautet "Die Agentur bleibt angebunden" -- und dafuer gab es
|
||||||
@@ -392,9 +392,13 @@
|
|||||||
die andere Seite des Tisches die Agentur ist.
|
die andere Seite des Tisches die Agentur ist.
|
||||||
Ton 9 (Orange) -- deutlich von seinen Nachbarn 1 (Blau) und
|
Ton 9 (Orange) -- deutlich von seinen Nachbarn 1 (Blau) und
|
||||||
14 (Gruen) entfernt. */
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'report.html', szene: 'showbuehne' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -420,16 +424,16 @@
|
|||||||
anderes sagt als die Software, ist schlimmer als beides
|
anderes sagt als die Software, ist schlimmer als beides
|
||||||
einzeln: Man weiss danach nicht mehr, welcher von beiden man
|
einzeln: Man weiss danach nicht mehr, welcher von beiden man
|
||||||
glauben soll. */
|
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' },
|
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' },
|
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' },
|
rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'wissen.html', szene: 'arena' },
|
||||||
/* Ebenfalls nur DogFather (02.09.2026). Was von allein passiert,
|
/* Ebenfalls nur DogFather (02.09.2026). Was von allein passiert,
|
||||||
legt fest, was ALLEN anderen automatisch zugeschoben wird --
|
legt fest, was ALLEN anderen automatisch zugeschoben wird --
|
||||||
das gehört in dieselbe Hand wie die Rollenvergabe. */
|
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' },
|
rollen: ['admin'], ziel: 'automation.html', szene: 'garage' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -529,8 +529,23 @@
|
|||||||
const strom = new EventSource('/workspace/api/chat/strom');
|
const strom = new EventSource('/workspace/api/chat/strom');
|
||||||
strom.addEventListener('neu', holen);
|
strom.addEventListener('neu', holen);
|
||||||
/* Nach einem Abriss verbindet der Browser selbst neu -- dann
|
/* Nach einem Abriss verbindet der Browser selbst neu -- dann
|
||||||
einmal nachzählen, was inzwischen kam. */
|
einmal nachzählen, was inzwischen kam.
|
||||||
strom.addEventListener('open', holen);
|
|
||||||
|
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 */ }
|
} catch { /* dann eben nur beim Laden */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -211,10 +211,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/aufgaben.js?v=202609062206" defer></script>
|
<script src="assets/js/aufgaben.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/automation.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/automation.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -134,10 +134,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/automation.js?v=202609062206" defer></script>
|
<script src="assets/js/automation.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-13
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/bereich.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -117,14 +117,14 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/checkliste.js?v=202609062206" defer></script>
|
<script src="assets/js/checkliste.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/ampel.js?v=202609062206" defer></script>
|
<script src="assets/js/ampel.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/schulung.js?v=202609062206" defer></script>
|
<script src="assets/js/schulung.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/vorlagen.js?v=202609062206" defer></script>
|
<script src="assets/js/vorlagen.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/bereich.js?v=202609062206" defer></script>
|
<script src="assets/js/bereich.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-10
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/calls.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/calls.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -76,11 +76,11 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/ki.js?v=202609062206" defer></script>
|
<script src="assets/js/ki.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/calls.js?v=202609062206" defer></script>
|
<script src="assets/js/calls.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+8
-8
@@ -9,16 +9,16 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
|
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
|
||||||
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
|
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
|
||||||
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
|
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
|
||||||
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
|
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
|
||||||
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
|
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
|
||||||
Dialog zu sehen bekam. -->
|
Dialog zu sehen bekam. -->
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/chat.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/chat.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -154,9 +154,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="assets/js/bereiche.js?v=202609062206"></script>
|
<script src="assets/js/bereiche.js?v=202609062315"></script>
|
||||||
<script src="assets/js/kopf.js?v=202609062206"></script>
|
<script src="assets/js/kopf.js?v=202609062315"></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206"></script>
|
<script src="assets/js/glocke.js?v=202609062315"></script>
|
||||||
<script src="assets/js/chat.js?v=202609062206"></script>
|
<script src="assets/js/chat.js?v=202609062315"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+11
-11
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/content.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/content.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -166,12 +166,12 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/checkliste.js?v=202609062206" defer></script>
|
<script src="assets/js/checkliste.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/vorlagen.js?v=202609062206" defer></script>
|
<script src="assets/js/vorlagen.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/content.js?v=202609062206" defer></script>
|
<script src="assets/js/content.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/dateien.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -95,10 +95,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/dateien.js?v=202609062206" defer></script>
|
<script src="assets/js/dateien.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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>
|
</head>
|
||||||
|
|
||||||
<body class="gate">
|
<body class="gate">
|
||||||
@@ -145,6 +145,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/gate.js?v=202609062206" defer></script>
|
<script src="assets/js/gate.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+61
-9
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/kalender.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -168,6 +168,57 @@
|
|||||||
<div class="k-serienliste" id="serien"></div>
|
<div class="k-serienliste" id="serien"></div>
|
||||||
</section>
|
</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>
|
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||||
|
|
||||||
<!-- Vier Ansichten auf DENSELBEN Daten. Der Server liefert einen
|
<!-- Vier Ansichten auf DENSELBEN Daten. Der Server liefert einen
|
||||||
@@ -206,10 +257,11 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/kalender.js?v=202609062206" defer></script>
|
<script src="assets/js/kalender.js?v=202609062315" defer></script>
|
||||||
|
<script src="assets/js/kalender-abo.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/leistung.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -199,10 +199,10 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206"></script>
|
<script src="assets/js/wahl.js?v=202609062315"></script>
|
||||||
<script src="assets/js/bereiche.js?v=202609062206"></script>
|
<script src="assets/js/bereiche.js?v=202609062315"></script>
|
||||||
<script src="assets/js/kopf.js?v=202609062206"></script>
|
<script src="assets/js/kopf.js?v=202609062315"></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206"></script>
|
<script src="assets/js/glocke.js?v=202609062315"></script>
|
||||||
<script src="assets/js/leistung.js?v=202609062206"></script>
|
<script src="assets/js/leistung.js?v=202609062315"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/personen.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/personen.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -123,10 +123,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/personen.js?v=202609062206" defer></script>
|
<script src="assets/js/personen.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-10
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/profil.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -201,11 +201,11 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/steckbrief.js?v=202609062206" defer></script>
|
<script src="assets/js/steckbrief.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/profil.js?v=202609062206" defer></script>
|
<script src="assets/js/profil.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-10
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/report.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/report.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -93,11 +93,11 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/ki.js?v=202609062206" defer></script>
|
<script src="assets/js/ki.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/report.js?v=202609062206" defer></script>
|
<script src="assets/js/report.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/scouting.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -126,10 +126,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/scouting.js?v=202609062206" defer></script>
|
<script src="assets/js/scouting.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -160,10 +160,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/start.js?v=202609062206" defer></script>
|
<script src="assets/js/start.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+10
-10
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -72,11 +72,11 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/ki.js?v=202609062206" defer></script>
|
<script src="assets/js/ki.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/startcheck.js?v=202609062206" defer></script>
|
<script src="assets/js/startcheck.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/profil.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/profil.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -113,10 +113,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/steckbrief.js?v=202609062206" defer></script>
|
<script src="assets/js/steckbrief.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -82,10 +82,10 @@
|
|||||||
|
|
||||||
</main>
|
</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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/uebersicht.js?v=202609062206" defer></script>
|
<script src="assets/js/uebersicht.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<meta name="theme-color" content="#0674b9" />
|
<meta name="theme-color" content="#0674b9" />
|
||||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
|
<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="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" />
|
||||||
<link rel="stylesheet" href="assets/css/start.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/start.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609062315" />
|
||||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202609062206" />
|
<link rel="stylesheet" href="assets/css/wissen.css?v=202609062315" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="start">
|
<body class="start">
|
||||||
@@ -177,10 +177,10 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="assets/js/wahl.js?v=202609062206" defer></script>
|
<script src="assets/js/wahl.js?v=202609062315" defer></script>
|
||||||
<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/kopf.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/glocke.js?v=202609062206" defer></script>
|
<script src="assets/js/glocke.js?v=202609062315" defer></script>
|
||||||
<script src="assets/js/wissen.js?v=202609062206" defer></script>
|
<script src="assets/js/wissen.js?v=202609062315" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user