Rund um das Team -- die Arbeitslage von Managern und Scouts

Filipe: "so eine kategorie wie ueber die creator will ich dass nur fuer
die spicy und dogfather rolle auch ueber manager und scouts gibt ...
ich will dass es so ultra krass gut ist dass die spicy und dogfather
rolle einen kompletten teil haben mit daten ueber die arbeit von den
manager und scout. keine geheimen sachen also termine, chats und
geheime dateien soll auch so bleiben dass keiner."

--- ZUERST DIE KOPFLEISTE ---

Filipe meldete, die Kopfleiste sei bei DogFather "nicht gemacht".
Nachgemessen auf ALLEN 18 Seiten, in allen fuenf Rollen, bei drei
Breiten: einzeilig, Spanne 4 px. Und die neuen Dateien liegen
nachweislich auf dem Server (a70bc4f, `abmelden__zeichen` in der
ausgelieferten kopf.js). Das Bild war vor dem Ausliefern entstanden.

Die Messung hat aber zwei echte Sachen gefunden, die vorher niemand
gesehen hatte -- beide bei 320 px auf UNTERseiten, wo links der
Zurueck-Knopf und rechts zusaetzlich die Glocke steht: 305 px
gebraucht, 294 verfuegbar. Eine Stufe kleiner (34 px je Knopf, 4 px
Abstand) macht 283 und passt; 34 px bleiben weit ueber den 24 px
Mindestmass fuer ein Beruehrziel.

Ausserdem die Auslieferung geordnet: HTML wird immer nachgefragt,
Dateien mit Versionsstempel duerfen ein Jahr liegenbleiben (vorher
bekam ALLES `no-cache`, also auch jede Stilvorlage bei jedem Aufruf).
sw.js ausgenommen -- er wird ohne Stempel geladen, ein Fehler darin
bliebe sonst ein Jahr stehen.

--- DIE NEUE SEITE ---

workspace/team.html, nur fuer `spicy` und `admin`. Aufbau:

  DIE LUECKEN ZUERST. Creator ohne Betreuung, Scouts ohne Manager,
  Leute ohne einen einzigen Creator -- mit NAMEN, nicht nur als Zahl.
  Eine Kennzahl sagt, wie es laeuft; eine Luecke sagt, wo etwas fehlt,
  und nur das Zweite kann man heute abstellen.

  DANN DIE LAGE in fuenf Zahlen, dann JEDE PERSON EINZELN: betreute
  Creator, laufende und ueberfaellige Aufgaben, in 30 Tagen erledigte,
  Durchlaufzeit, Startcheck-Fortschritt der betreuten Creator,
  LIVE-Tage und Diamanten. Bei Scouts zusaetzlich die Pipeline mit
  Uebernahmequote und Zeit bis zur Uebergabe.

  EIN MANAGER TRAEGT DIE CREATOR SEINER SCOUTS MIT. Ohne das saehe
  einer mit fuenf Scouts aus wie jemand ohne Arbeit.

--- DREI ENTSCHEIDUNGEN, DIE ALLES TRAGEN ---

1. TERMINE, CHATS UND DATEIEN KOMMEN NICHT VOR -- weder Inhalte noch
   Zaehlungen. Ausdruecklicher Wunsch, und der richtige: Ein Kalender
   verraet, wann jemand nicht da war; ein Chatzaehler, mit wem jemand
   oft spricht.

   Das ist keine Zusicherung im Kommentar. pruef-team liest den
   Quelltext von workspace-team.js und schlaegt an, wenn eine dieser
   Tabellen darin auftaucht -- mit Gegenprobe, dass die Suche `aufgaben`
   und `leads` auch wirklich findet. Der Weg ueber die Antwort allein
   waere schwaecher: Ein leerer Testbestand kann ein Feld verstecken.

2. SEGMENTIEREN, NICHT MITTELN. Aus der Recherche zu
   Arbeitslast-Dashboards: Ein Durchschnitt versteckt genau die Person,
   bei der es klemmt. Markiert wird gegen den MEDIAN der eigenen Rolle
   -- ein Manager traegt naturgemaess mehr als ein Scout, und ihn daran
   zu messen waere unfair und nutzlos.

3. ES IST EINE ARBEITSLAGE, KEINE UEBERWACHUNG. Das steht so auf der
   Seite, im Kopf, in einem eigenen Kasten. Wer das nicht dazuschreibt,
   baut ein Kontrollwerkzeug, auch wenn er es nicht wollte. Deshalb
   zeigen die Kennzahlen auf ZUSTAENDE (unbetreute Creator,
   liegengebliebene Kontakte) und nicht auf Anwesenheit oder Fleiss.

--- WAS DIE MESSUNG UNTERWEGS GEFUNDEN HAT ---

* Die Lead-Status hiessen anders, als ich angenommen hatte: "kontakt"
  gibt es nicht. Die CHECK-Bedingung der Datenbank hat es sofort
  abgelehnt -- ohne sie waere "offen" still zu klein gewesen.

* Die Pruefung fand ihr eigenes Hinweisschild: Die Antwort traegt ein
  Feld `ausgenommen: ["Termine","Chats","Dateien"]`, aus dem die Seite
  den Satz baut. Es wird jetzt herausgenommen UND eigens geprueft --
  ignorieren waere bequem gewesen und haette kuenftig jedes Feld unter
  diesem Namen durchgelassen.

* Die Lektion vom Vorlagenbrett gleich mitgenommen: alle Karten haben
  einen DECKENDEN Grund. Eine Karte mit sieben Prozent Farbe auf
  durchsichtigem Grund laesst das Buehnenfoto durch -- dort waren es
  3,61:1. Gemessen jetzt: 6,61 bis 14,80:1. Eine der Regeln hatte den
  deckenden Grund selbst wieder aufgehoben (zwei Regeln, die spaetere
  gewinnt) -- gefunden, bevor es jemand sehen musste.

* "1 Scouts" statt "1 Scout". Eine Kleinigkeit, und das Erste, was
  auffaellt: Eine Seite, die ihre eigene Sprache nicht beherrscht, wird
  auch bei den Zahlen nicht geglaubt.

--- Pruefung ---

server/pruef-team.mjs, neu, 51 Pruefungen, alle gruen. Darunter: alle
fuenf Rollen an Schnittstelle UND Seite (Manager, Scout und Creator
bekommen 404 bzw. eine Umleitung), Spicy sieht VanVan nicht (mit
Gegenprobe an der Personenliste), die Zahlen an einem gebauten
Bestand, acht Kontrastmessungen an der wirklichen Flaeche, Handy.

server/pruef-zwischenspeicher.mjs, neu, 15 Pruefungen: was liegenbleiben
darf und was nicht -- an echten Kopfzeilen gemessen, nicht am
Quelltext. Sie meldete zuerst drei Fehler, und das war sie selbst: Sie
fragte unangemeldet und bekam Umleitungen. Eine Pruefung braucht ihre
Voraussetzung, bevor sie misst.

Ausserdem gruen: pruef-handy (das Handy fand die 320-px-Sache),
pruef-workspace-seiten, pruef-alle-wege, pruef-sicht, pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-09 15:42:14 +02:00
co-authored by Claude Opus 5
parent a70bc4f235
commit b45de940e8
29 changed files with 1878 additions and 482 deletions
+67 -1
View File
@@ -35,6 +35,10 @@ import { contentRouter } from "./workspace-content.js";
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
import { steckbriefRouter } from "./workspace-steckbrief.js"; import { steckbriefRouter } from "./workspace-steckbrief.js";
import { vorlagenRouter } from "./workspace-vorlagen.js"; import { vorlagenRouter } from "./workspace-vorlagen.js";
/* Die Arbeitslage von Managern und Scouts -- nur fuer Spicy Media und
DogFather. Sie ruehrt Termine, Chats und Dateien nicht an; die
Begruendung steht im Kopf der Datei. */
import { teamRouter } from "./workspace-team.js";
import { schulungRouter } from "./workspace-schulung.js"; import { schulungRouter } from "./workspace-schulung.js";
import { ampelRouter } from "./workspace-ampel.js"; import { ampelRouter } from "./workspace-ampel.js";
import { checklisteRouter } from "./workspace-checkliste.js"; import { checklisteRouter } from "./workspace-checkliste.js";
@@ -203,6 +207,7 @@ app.use(fruehwarnungRouter);
deshalb der Zugang -- 32 Byte Zufall, jederzeit widerrufbar. */ deshalb der Zugang -- 32 Byte Zufall, jederzeit widerrufbar. */
app.use(icsRouter); app.use(icsRouter);
app.use(vorlagenRouter); app.use(vorlagenRouter);
app.use(teamRouter);
app.use(schulungRouter); app.use(schulungRouter);
app.use(ampelRouter); app.use(ampelRouter);
app.use(checklisteRouter); app.use(checklisteRouter);
@@ -297,7 +302,68 @@ app.use((req, res, next) => {
next(); next();
}); });
app.use(express.static(SITE_DIR, { index: "index.html" })); /* =====================================================================
HTML WIRD IMMER NACHGEFRAGT, ALLES ANDERE DARF LIEGENBLEIBEN
(09.09.2026)
DER FALL, DER ES GEZEIGT HAT: Filipe meldete am Nachmittag, die
Kopfleiste auf dem Handy sei "nicht gemacht" -- gemessen war sie auf
allen 18 Seiten, in allen fuenf Rollen und bei drei Breiten
einzeilig, und die neuen Dateien lagen nachweislich auf dem Server.
Er sah trotzdem den alten Stand.
Die Ursache ist eine Luecke, die man nur bemerkt, wenn man sie sucht:
Die HTML-Seiten gingen OHNE JEDE Cache-Angabe hinaus. Ohne
`Cache-Control` und ohne `Expires` darf ein Browser heuristisch
zwischenspeichern -- ueblicherweise ein Zehntel des Alters seit der
letzten Aenderung. Bei einer Datei, die vor zwei Wochen zuletzt
angefasst wurde, sind das anderthalb Tage.
Und das ist schlimmer als es klingt: In der HTML stehen die
VERSIONSSTEMPEL der Stilvorlagen (`start.css?v=...`). Eine alte HTML
verweist auf alte Stempel -- der Browser holt sich dann brav die
ALTEN CSS- und JS-Dateien, obwohl die neuen bereitliegen. Der ganze
Stempelmechanismus haengt an einer Datei, die selbst
zwischengespeichert werden durfte.
Deshalb: HTML immer nachfragen (`no-cache` heisst NICHT "nicht
speichern", sondern "vor jeder Benutzung rueckfragen" -- die Antwort
ist meistens ein 304 ohne Rumpf und kostet fast nichts). Alles mit
Stempel darf dagegen ein Jahr liegenbleiben; sein Name aendert sich
ja, sobald sich der Inhalt aendert.
Bilder und Schriften ohne Stempel bekommen einen Tag -- lang genug,
um jeden Seitenaufruf zu tragen, kurz genug, um nach einem Austausch
nicht ewig alt zu bleiben. */
app.use(express.static(SITE_DIR, {
index: "index.html",
setHeaders(res, pfad) {
if (pfad.endsWith(".html")) {
res.setHeader("Cache-Control", "no-cache");
return;
}
/* Mit Versionsstempel in der Adresse: Der Name ist der Beweis.
`originalUrl` steht hier nicht zur Verfuegung -- gepruefft wird
deshalb die Dateiart, und die Stempel setzt ohnehin nur das
Haus selbst. */
/* AUSNAHME: sw.js. Der Service Worker wird OHNE Stempel geladen
(glocke.js: register('/workspace/sw.js')). Ein Jahr unveraenderlich
hiesse: Ein Fehler darin bliebe ein Jahr stehen. Browser
begrenzen das zwar selbst auf 24 Stunden -- sich darauf zu
verlassen waere aber genau die Sorte Annahme, die still bricht. */
if (pfad.endsWith("sw.js")) {
res.setHeader("Cache-Control", "no-cache");
return;
}
if (/\.(css|js|mjs)$/.test(pfad)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
if (/\.(webp|png|jpe?g|svg|gif|ico|woff2?)$/.test(pfad)) {
res.setHeader("Cache-Control", "public, max-age=86400");
}
},
}));
app.use((req, res) => { app.use((req, res) => {
res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => { res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => {
+401 -290
View File
@@ -1,72 +1,72 @@
/* Prueft zwei Wuensche vom 01.09.2026: /* DIE ARBEITSLAGE VON MANAGERN UND SCOUTS (09.09.2026)
SCREEN 2: "ich will, dass wir Manager, Scouts, DogFather auch die Filipe: "so eine kategorie wie über die creator will ich dass nur für
Fotos, Namen und so alles sehen, alles was die in ihrem Profil die spicy und dogfather rolle auch über manager und scouts gibt ...
eintragen." keine geheimen sachen also termine, chats und geheime dateien soll
auch so bleiben dass keiner."
SCREEN 5: "wieso seh ich mein Bild da nicht, obwohl ich schon ein Foto ---------------------------------------------------------------------
drin hab?" DER WICHTIGSTE ABSCHNITT IST DER ERSTE, NICHT DER LETZTE
WAS AN DIESER PRUEFUNG WICHTIG IST. Diese Seite zeigt Zahlen über Kollegen. Zwei Dinge daran können
schiefgehen, und beide wären schwer wieder gutzumachen:
Ein Profilbild zu pruefen, ohne ein echtes Bild zu haben, beweist 1. Sie zeigt sie den Falschen. Ein Manager, der die Auslastung
nichts. Beim .wer__bild-Fehler im August benutzte die Pruefung ein seiner Kollegen sieht, hat eine Auskunft, die ihm nicht gehört.
1x1-Pixel-PNG -- damit kann kein Stil-Fehler sichtbar werden, weil 2. Sie zeigt etwas, das nicht hineingehört -- Termine, Chats,
ein Punkt nichts ueberdecken kann. Hier wird deshalb ein RICHTIGES Dateien.
Bild hochgeladen (600x600) und danach gemessen, ob es auch die
Flaeche fuellt, die es fuellen soll -- und ob es rund beschnitten ist
statt quadratisch ueber den Rahmen zu ragen.
Und zu jeder Aussage die Gegenprobe: Wer KEIN Bild hat, muss den Punkt 2 wird hier auf ZWEI Wegen geprüft: an der Antwort (keine
Buchstaben sehen. Sonst koennte die Pruefung gruen sein, weil Felder mit diesen Namen) UND am Quelltext (die Datei nennt diese
ueberall Bilder erscheinen -- auch da, wo keine sind. */ Tabellen nicht). Der zweite Weg ist der stärkere: Er hält auch dann,
wenn jemand morgen ein Feld hinzufügt, das im Testbestand zufällig
leer bleibt.
import { mkdtempSync, rmSync } from "node:fs"; DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join, dirname } from "node:path";
import { deflateSync } from "node:zlib"; import { fileURLToPath } from "node:url";
const ordner = mkdtempSync(join(tmpdir(), "ws-team-")); const ordner = mkdtempSync(join(tmpdir(), "ws-team-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.WORKSPACE_DB = join(ordner, "workspace.db");
/* DATEN_ORDNER wird aus dem Datenbankpfad abgeleitet (workspace.js) --
die Profilbilder landen also von selbst im Testordner. */ import { notbremse } from "./helfer-notbremse.mjs";
process.env.PORT = "4213"; const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4345, "die Team-Pruefung");
process.env.PORT = "4345";
process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default; const express = (await import("express")).default;
const ec = express.response.cookie; const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js"); await import("./index.js");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort notbremse(300_000, "pruef-team");
zwei Auffangnetze ein, die einen Absturz nur protokollieren -- await new Promise((r) => setTimeout(r, 900));
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese const BASIS = "http://127.0.0.1:4345";
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-team");
await new Promise((r) => setTimeout(r, 700));
const BASIS = "http://127.0.0.1:4213";
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.();
let fehler = 0; let fehler = 0, geprueft = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", { await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHash } = await import("node:crypto");
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
try { try { const p = new DatabaseSync(process.env.WORKSPACE_DB);
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); } } catch { await new Promise((r) => setTimeout(r, 250)); }
} }
const d = new DatabaseSync(process.env.WORKSPACE_DB); const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString(); const jetzt = new Date().toISOString();
const tagT = (v) => { const x = new Date(Date.now() + v * 86400000);
return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}`
+ `-${String(x.getDate()).padStart(2, "0")}`; };
function anlegen(name, rolle, code) { function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
@@ -75,267 +75,378 @@ function anlegen(name, rolle, code) {
return d.prepare("SELECT last_insert_rowid() AS id").get().id; return d.prepare("SELECT last_insert_rowid() AS id").get().id;
} }
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001"); const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idVan = anlegen("VanVan", "admin", "CODE-VANV-0001"); /* der zweite Admin */
const idMara = anlegen("Mara", "manager", "CODE-MANA-0001");
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idBen = anlegen("Ben", "scout", "CODE-SCOB-0001");
const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") const idLuna = anlegen("Luna", "creator", "CODE-LUNA-0001");
.run(idTili, idScout, jetzt); const idRoy = anlegen("Roy", "creator", "CODE-ROYY-0001");
const idFrei = anlegen("Nio", "creator", "CODE-NIOO-0001"); /* ohne Betreuung */
/* Jeder traegt etwas in seinen Steckbrief ein -- sonst koennte die /* Patrick betreut Tili und Luna, Ben betreut Roy, Mara fuehrt Patrick.
Pruefung nicht unterscheiden, ob "alles was die eintragen" wirklich Nio hat NIEMANDEN -- das ist die Luecke, die die Seite zeigen soll. */
ankommt oder ob nur Namen angezeigt werden. */ d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idPat, jetzt);
d.prepare("UPDATE personen SET ueber_mich = ?, tiktok = ? WHERE id = ?") d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
.run("Manager & CEO dieser Seite", "dogfather", idDogi); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idRoy, idBen, jetzt);
d.prepare("UPDATE personen SET ueber_mich = ?, instagram = ? WHERE id = ?") d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
.run("Ich kuemmere mich um die Vertraege.", "mara.mgmt", idMgr);
d.prepare("UPDATE personen SET ueber_mich = ? WHERE id = ?") const auf = (titel, creator, status, frist) =>
.run("Ich finde neue Leute.", idScout); d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
/* Tili bekommt BEWUSST nichts -- die Gegenprobe zum Platzhaltersatz. */ creator_id, verantwortlich_id, erstellt, erstellt_von, erledigt_am)
VALUES (?,?,?,'mittel',?,?,?,?,?,?)`)
.run(titel, "", status, frist, creator, creator, jetzt, idDogi,
status === "erledigt" ? jetzt : null);
auf("T1", idTili, "offen", tagT(-3)); /* ueberfaellig */
auf("T2", idTili, "arbeit", tagT(5));
auf("T3", idLuna, "erledigt", tagT(-1));
auf("T4", idRoy, "offen", tagT(2));
d.prepare(`INSERT INTO leads (name, plattform, handle, status, scout_id, erstellt, erstellt_von,
naechster_followup, uebergeben_am) VALUES (?,?,?,?,?,?,?,?,?)`)
.run("Kandidat A", "tiktok", "@a", "angesprochen", idPat, jetzt, idPat, tagT(-2), null);
d.prepare(`INSERT INTO leads (name, plattform, handle, status, scout_id, erstellt, erstellt_von,
naechster_followup, uebergeben_am) VALUES (?,?,?,?,?,?,?,?,?)`)
.run("Kandidat B", "tiktok", "@b", "uebergeben", idPat, jetzt, idPat, null, jetzt);
d.close(); d.close();
/* ---- Ein echtes PNG, 600x600, einfarbig. Kein 1x1-Punkt: Der kann const keks = async (rolle, code) => {
nichts ueberdecken und wuerde jeden Stil-Fehler verstecken. ---- */ const a = await fetch(BASIS + "/workspace/api/anmelden", {
function pngBauen(breite, hoehe) { method: "POST", headers: { "Content-Type": "application/json" },
const roh = Buffer.alloc((breite * 3 + 1) * hoehe); body: JSON.stringify({ rolle, code }) });
for (let y = 0; y < hoehe; y++) { return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
const z = y * (breite * 3 + 1); };
roh[z] = 0; const kDogi = await keks("admin", "CODE-DOGI-0001");
for (let x = 0; x < breite; x++) { const kSpicy = await keks("spicy", "CODE-SPIC-0001");
roh[z + 1 + x * 3] = 200; roh[z + 2 + x * 3] = 60; roh[z + 3 + x * 3] = 120; const kMara = await keks("manager", "CODE-MANA-0001");
} const kPat = await keks("scout", "CODE-SCOU-0001");
const kTili = await keks("creator", "CODE-ANNA-0001");
const ruf = (pfad, k) => fetch(BASIS + pfad, { headers: { cookie: k }, redirect: "manual" });
/* =======================================================================
1. WER DARF HINEIN -- und wer nicht
======================================================================= */
console.log("\n=== Wer darf hinein");
{
for (const [name, k, soll] of [
["DogFather", kDogi, 200], ["Spicy Media", kSpicy, 200],
["Manager", kMara, 404], ["Scout", kPat, 404], ["Creator", kTili, 404],
]) {
const a = await ruf("/workspace/api/team", k);
ok(a.status === soll, `${name}: HTTP ${a.status} (erwartet ${soll})`);
} }
const stueck = (art, daten) => { /* Ohne Anmeldung ueberhaupt nichts. */
const l = Buffer.alloc(4); l.writeUInt32BE(daten.length); const ohne = await fetch(BASIS + "/workspace/api/team", { redirect: "manual" });
const k = Buffer.concat([Buffer.from(art), daten]); ok(ohne.status === 401, `ohne Anmeldung: HTTP ${ohne.status}`);
const c = Buffer.alloc(4);
c.writeUInt32BE(crc32(k) >>> 0); /* Und die SEITE ebenso -- eine offene Seite mit gesperrter
return Buffer.concat([l, k, c]); Schnittstelle waere eine leere Seite und eine offene Frage. */
}; for (const [name, k, offen] of [
const ihdr = Buffer.alloc(13); ["DogFather", kDogi, true], ["Spicy Media", kSpicy, true],
ihdr.writeUInt32BE(breite, 0); ihdr.writeUInt32BE(hoehe, 4); ["Manager", kMara, false], ["Scout", kPat, false], ["Creator", kTili, false],
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; ]) {
return Buffer.concat([ const a = await ruf("/workspace/team.html", k);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), const drin = a.status === 200;
stueck("IHDR", ihdr), stueck("IDAT", deflateSync(roh)), stueck("IEND", Buffer.alloc(0)), ok(drin === offen, `team.html für ${name}: HTTP ${a.status}`);
]);
}
let tabelle = null;
function crc32(buf) {
if (!tabelle) {
tabelle = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
tabelle[n] = c;
}
} }
let c = -1;
for (const b of buf) c = tabelle[(c ^ b) & 0xff] ^ (c >>> 8);
return c ^ -1;
} }
const BILD = pngBauen(600, 600);
/* ---------------------------------------------------------------- */ /* =======================================================================
2. WAS NICHT HINEINGEHÖRT -- Termine, Chats, Dateien
const { chromium } = await import( Auf zwei Wegen geprüft. Der zweite ist der stärkere.
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); ======================================================================= */
const browser = await chromium.launch(); console.log("\n=== Termine, Chats und Dateien bleiben draußen");
{
const daten = await (await ruf("/workspace/api/team", kDogi)).json();
async function anmelden(rolle, code) { /* DAS HINWEISSCHILD WIRD AUSGENOMMEN -- und zwar ausdrücklich.
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
Die Antwort trägt ein Feld `ausgenommen: ["Termine","Chats",
"Dateien"]`; daraus schreibt die Seite den Satz, der dem Betrachter
sagt, was hier NICHT steht. Beim ersten Lauf hat die Wortsuche
genau dieses Schild gefunden und Alarm geschlagen.
Es einfach zu ignorieren wäre die bequeme Lösung und eine schlechte:
Dann könnte künftig jedes beliebige Feld unter diesem Namen
durchrutschen. Stattdessen wird es HERAUSGENOMMEN und danach
eigens geprüft -- was es enthalten darf, steht dann fest. */
const { ausgenommen, ...ohneSchild } = daten;
const text = JSON.stringify(ohneSchild).toLowerCase();
const verboten = ["termin", "chat", "datei", "nachricht", "anhang"];
const gefunden = verboten.filter((w) => text.includes(w));
ok(gefunden.length === 0,
gefunden.length ? `in der Antwort steht: ${gefunden.join(", ")}`
: `kein einziges dieser Wörter in der Antwort (${verboten.join(", ")})`);
ok(Array.isArray(ausgenommen) && ausgenommen.join(",") === "Termine,Chats,Dateien",
`und das Schild nennt genau die drei (${(ausgenommen || []).join(", ")})`);
/* GEGENPROBE ZUR WORTSUCHE: Sie muss ein verbotenes Wort auch finden.
Ohne sie hiesse "nichts gefunden" nur, dass nicht gesucht wurde. */
ok(verboten.some((w) => JSON.stringify({ x: "Termine am Montag" }).toLowerCase().includes(w)),
"Gegenprobe: die Wortsuche schlägt bei einem verbotenen Wort an");
/* DER STÄRKERE WEG: der Quelltext selbst. Ein leerer Testbestand
kann ein Feld verstecken; eine Abfrage, die es gar nicht gibt,
kann nichts liefern. */
const HIER = dirname(fileURLToPath(import.meta.url));
const quelle = readFileSync(join(HIER, "workspace-team.js"), "utf8");
/* Nur der CODE, nicht die Kommentare -- dort steht ausdrücklich, was
ausgenommen ist, und das soll dort auch stehen bleiben. */
const nurCode = quelle.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
const tabellen = ["termine", "termin_teilnehmer", "chat_nachrichten", "chat_raeume",
"chat_teilnehmer", "dateien", "datei_personen"];
const benutzt = tabellen.filter((t) => new RegExp(`\\b${t}\\b`).test(nurCode));
ok(benutzt.length === 0,
benutzt.length ? `der Quelltext fragt ab: ${benutzt.join(", ")}`
: `keine dieser ${tabellen.length} Tabellen wird abgefragt`);
/* GEGENPROBE: Die Suche findet eine Tabelle, die WIRKLICH benutzt
wird. Ohne sie hiesse "nichts gefunden" nur, dass die Suche nicht
sucht. */
ok(/\baufgaben\b/.test(nurCode) && /\bleads\b/.test(nurCode),
"Gegenprobe: `aufgaben` und `leads` werden gefunden — die Suche sucht wirklich");
}
/* =======================================================================
3. STIMMEN DIE ZAHLEN?
======================================================================= */
console.log("\n=== Die Zahlen");
{
const daten = await (await ruf("/workspace/api/team", kDogi)).json();
const p = (name) => daten.leute.find((x) => x.name === name);
ok(daten.leute.length === 3,
`drei Leute: Manager und zwei Scouts (${daten.leute.map((x) => x.name).join(", ")})`);
ok(!daten.leute.some((x) => x.rolle === "creator" || x.rolle === "admin"),
"keine Creator und keine Admins in der Liste — die Seite ist für Manager und Scouts");
const pat = p("Patrick");
ok(pat?.creator.length === 2, `Patrick betreut zwei Creator (${pat?.creator.length})`);
ok(pat?.aufgaben.ueberfaellig === 1,
`eine überfällige Aufgabe bei seinen Creators (${pat?.aufgaben.ueberfaellig})`);
ok(pat?.aufgaben.laufend === 2, `zwei laufende (${pat?.aufgaben.laufend})`);
/* DER MANAGER TRÄGT DIE CREATOR SEINER SCOUTS MIT.
Ohne das sähe ein Manager mit fünf Scouts aus wie jemand ohne
Arbeit -- und genau das wäre die falsche Auskunft. */
const mara = p("Mara");
ok(mara?.creator.length === 0 && mara?.creator_mittelbar === 2,
`Mara betreut niemanden direkt, aber zwei über Patrick `
+ `(${mara?.creator.length} / ${mara?.creator_mittelbar})`);
ok(mara?.aufgaben.ueberfaellig === 1,
`und sieht deren überfällige Aufgabe (${mara?.aufgaben.ueberfaellig})`);
ok(mara?.scouts.includes("Patrick"), `mit Namen des Scouts (${mara?.scouts.join(", ")})`);
/* Die Pipeline gibt es nur bei Scouts. */
ok(pat?.pipeline && pat.pipeline.gesamt === 2 && pat.pipeline.uebergeben === 1,
`Patricks Pipeline: ${pat?.pipeline?.gesamt} Kontakte, ${pat?.pipeline?.uebergeben} übergeben`);
ok(pat?.pipeline?.faellig === 1,
`davon einer überfällig im Nachfassen (${pat?.pipeline?.faellig})`);
ok(mara?.pipeline === null, "ein Manager hat keine Pipeline — und bekommt auch keine leere");
/* ---- DIE LÜCKEN ---- */
ok(daten.luecken.creator_ohne_betreuung.some((c) => c.name === "Nio"),
`Nio steht als Creator ohne Betreuung da `
+ `(${daten.luecken.creator_ohne_betreuung.map((c) => c.name).join(", ")})`);
ok(daten.luecken.scouts_ohne_manager.some((s) => s.name === "Ben"),
`Ben steht als Scout ohne Manager da `
+ `(${daten.luecken.scouts_ohne_manager.map((s) => s.name).join(", ")})`);
/* GEGENPROBE: Wer versorgt ist, taucht NICHT auf. */
ok(!daten.luecken.creator_ohne_betreuung.some((c) => c.name === "Tili")
&& !daten.luecken.scouts_ohne_manager.some((s) => s.name === "Patrick"),
"und die versorgten stehen nicht darin");
}
/* =======================================================================
4. SPICY MEDIA SIEHT VANVAN NICHT
Die Hausregel gilt auch hier -- und zwar über dieselbe zentrale
Funktion, nicht über eine eigene Fassung.
======================================================================= */
console.log("\n=== Niemand sieht VanVan außer DogFather");
{
const alsSpicy = await (await ruf("/workspace/api/team", kSpicy)).json();
const text = JSON.stringify(alsSpicy);
ok(!text.includes("VanVan"), "in der Spicy-Ansicht steht VanVan nirgends");
/* Sie ist Admin und stünde ohnehin nicht in der Liste -- geprüft wird
deshalb, dass die Regel überhaupt greift: DogFather sieht sie in
der Personenliste, Spicy nicht. */
const beiDogi = await (await ruf("/workspace/api/verwaltung/personen", kDogi)).json();
const beiSpicy = await (await ruf("/workspace/api/verwaltung/personen", kSpicy)).json();
const nDogi = (beiDogi.personen || []).some((x) => x.name === "VanVan");
const nSpicy = (beiSpicy.personen || []).some((x) => x.name === "VanVan");
ok(nDogi && !nSpicy,
`Gegenprobe an der Personenliste: DogFather sieht sie (${nDogi}), Spicy nicht (${nSpicy})`);
}
/* =======================================================================
5. IM BROWSER
======================================================================= */
console.log("\n=== Auf der Seite");
{
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const konsole = []; const konsole = [];
const seite = await kontext.newPage(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); const s = await ctx.newPage();
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); s.on("pageerror", (f) => konsole.push("Absturz: " + f.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); s.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); });
await seite.click(`.rolle[data-rolle="${rolle}"]`); await s.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.fill("#code", code); await s.click('.rolle[data-rolle="admin"]');
await seite.click("button[type=submit]"); await s.fill("#code", "CODE-DOGI-0001");
await seite.waitForURL("**/start.html", { timeout: 15000 }); await s.click("button[type=submit]");
return { kontext, seite, konsole }; await s.waitForURL("**/start.html", { timeout: 20000 });
await s.waitForTimeout(1200);
/* Die Kachel steht auf der Startseite und führt hin. */
const kachel = await s.evaluate(() => {
const k = [...document.querySelectorAll(".kachel")]
.find((x) => /Team-Lage/.test(x.textContent));
return k ? { da: true, ziel: k.getAttribute("href") || k.dataset.ziel || "",
gruppe: k.closest(".gruppe")?.textContent?.slice(0, 40) } : { da: false };
});
ok(kachel.da, `die Kachel steht auf der Startseite (Gruppe: ${kachel.gruppe})`);
await s.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" });
await s.waitForTimeout(1500);
const m = await s.evaluate(() => ({
leute: document.querySelectorAll(".tperson").length,
zahlen: document.querySelectorAll(".tz").length,
luecken: document.querySelectorAll(".tl").length,
pipelines: document.querySelectorAll(".tpipe").length,
grenze: document.querySelector(".t-kopf__grenze")?.textContent?.trim().slice(0, 60),
warnungen: document.querySelectorAll('.tz[data-art="warn"]').length,
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}));
ok(m.leute === 3, `drei Personenkarten (${m.leute})`);
ok(m.pipelines === 2, `zwei Pipelines — nur die Scouts (${m.pipelines})`);
ok(m.luecken >= 2, `die Lücken stehen oben (${m.luecken} Kästen)`);
ok(m.warnungen > 0, `und was liegengeblieben ist, ist markiert (${m.warnungen})`);
ok(/Termine, Chats und Dateien/.test(m.grenze || ""),
`der Kopf sagt, was NICHT darin steht ("${m.grenze}…")`);
ok(m.ueber <= 1, `nichts steht seitlich über (${m.ueber} px)`);
if (process.env.BILD === "ja") {
await s.screenshot({ path: "_team-gross.png", fullPage: true });
}
/* Am Handy ebenfalls. */
const ctx2 = await browser.newContext({ viewport: { width: 390, height: 900 } });
const s2 = await ctx2.newPage();
await s2.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await s2.click('.rolle[data-rolle="admin"]');
await s2.fill("#code", "CODE-DOGI-0001");
await s2.click("button[type=submit]");
await s2.waitForURL("**/start.html", { timeout: 20000 });
await s2.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" });
await s2.waitForTimeout(1400);
const h = await s2.evaluate(() => ({
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
leute: document.querySelectorAll(".tperson").length,
breiter: [...document.querySelectorAll(".tperson, .tz, .tl")]
.filter((e) => e.getBoundingClientRect().right > 391).length,
}));
ok(h.ueber <= 1 && h.breiter === 0,
`am Handy nichts über (${h.ueber} px, ${h.breiter} zu breit) bei ${h.leute} Karten`);
if (process.env.BILD === "ja") {
await s2.screenshot({ path: "_team-handy.png", fullPage: true });
}
await ctx2.close();
/* ---- LESBAR, NICHT NUR DA (09.09.2026) --------------------------
Die Lektion vom Vorlagenbrett: Eine Karte mit sieben Prozent Farbe
auf durchsichtigem Grund laesst das Buehnenfoto durch, und dann
haengt die Lesbarkeit davon ab, welche Stelle des Fotos gerade
dahinterliegt. Dort waren es 3,61:1, wo 4,5 noetig sind.
Gemessen wird gegen die WIRKLICHE Flaeche: Der Text wird kurz
unsichtbar gemacht und die Flaeche fotografiert. */
{
const leuchtkraft = (r, g, b) => {
const f = (w) => { const v = w / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const kontrast = (a, b) => {
const l1 = leuchtkraft(...a), l2 = leuchtkraft(...b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
async function messen(waehler) {
const stelle = await s.evaluate((w) => {
const e = document.querySelector(w);
if (!e) return null;
e.scrollIntoView({ block: "center" });
const r = e.getBoundingClientRect();
if (r.width < 8 || r.height < 6) return null;
/* Die Farbe wird GEMALT und zurueckgelesen -- getComputedStyle
liefert bei color-mix() "color(srgb ...)", und ein Muster
ueber die Ziffern liest daraus Unsinn. */
const c = document.createElement("canvas");
c.width = 1; c.height = 1;
const g = c.getContext("2d", { willReadFrequently: true });
g.fillStyle = "#000000";
g.fillStyle = getComputedStyle(e).color;
g.fillRect(0, 0, 1, 1);
const px = g.getImageData(0, 0, 1, 1).data;
e.dataset.messen = "ja";
e.style.color = "transparent";
return { x: Math.round(r.x), y: Math.round(r.y),
w: Math.round(r.width), h: Math.round(Math.min(r.height, 40)),
farbe: [px[0], px[1], px[2]] };
}, waehler);
if (!stelle) return null;
const bild = await s.screenshot({
clip: { x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h } });
const hinten = await s.evaluate(async (b64) => {
const bild = new Image();
await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d", { willReadFrequently: true });
g.drawImage(bild, 0, 0);
const dd = g.getImageData(0, 0, c.width, c.height).data;
let r = 0, gr = 0, bl = 0, n = 0;
for (let i = 0; i < dd.length; i += 4) { r += dd[i]; gr += dd[i + 1]; bl += dd[i + 2]; n++; }
return [Math.round(r / n), Math.round(gr / n), Math.round(bl / n)];
}, bild.toString("base64"));
await s.evaluate(() => {
const e = document.querySelector('[data-messen="ja"]');
if (e) { e.style.color = ""; delete e.dataset.messen; }
});
return { farbe: stelle.farbe, hinten, wert: kontrast(stelle.farbe, hinten) };
}
for (const [name, waehler] of [
["Lücken-Titel", ".tl__titel"],
["Lücken-Namen", ".tl__namen"],
["Kennzahl", ".tz__wert"],
["Beschriftung", ".tz__schild"],
["Kennzahl (Warnung)", '.tz[data-art="warn"] .tz__wert'],
["Name", ".tperson__name"],
["Zuletzt da", ".tperson__zuletzt"],
["Grenzsatz", ".t-kopf__grenze"],
]) {
const m = await messen(waehler);
ok(m !== null && m.wert >= 4.5,
`${name}: ${m ? m.wert.toFixed(2) + ":1 (" + m.farbe.join(",") + " auf " + m.hinten.join(",") + ")" : "nicht gefunden"}`);
}
/* GEGENPROBE: ein absichtlich zu dunkler Text MUSS durchfallen. */
await s.evaluate(() => { document.querySelector(".tz__wert").style.color = "#131820"; });
const schlecht = await messen(".tz__wert");
ok(schlecht !== null && schlecht.wert < 4.5,
`absichtlich zu dunkler Text fällt durch (${schlecht?.wert.toFixed(2)}:1)`);
await s.evaluate(() => { document.querySelector(".tz__wert").style.color = ""; });
}
ok(konsole.length === 0, konsole.length ? "Konsole: " + konsole[0] : "keine Fehler in der Konsole");
/* GEGENPROBE zum Lauscher. */
await s.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
await s.waitForTimeout(400);
ok(konsole.some((z) => /Absicht/.test(z)), "ein absichtlicher Fehler wird bemerkt");
await ctx.close();
await browser.close();
} }
/* ================================================================ console.log(`\n${geprueft} Prüfungen`);
1. Ein echtes Bild hochladen -- und pruefen, dass es ueberall ankommt console.log(fehler ? `${fehler} FEHLER` : "ALLES IN ORDNUNG");
================================================================ */
console.log("\n=== Das eigene Bild (Screen 5)");
const a1 = await anmelden("admin", "CODE-DOGI-0001");
const hoch = await a1.seite.evaluate(async (b64) => {
const roh = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const a = await fetch("/workspace/api/steckbrief/bild", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "image/png" }, body: roh,
});
return { status: a.status, ...(await a.json().catch(() => ({}))) };
}, BILD.toString("base64"));
ok(hoch.status === 200 && !!hoch.bild, `ein echtes Bild (600x600) laesst sich hochladen (${hoch.status})`);
/* Kommt es auf der STARTSEITE in der Begruessung an? Das war der
gemeldete Fehler: Dort stand der Buchstabe, obwohl ein Foto da war --
und im Server steht seit jeher der Kommentar "es steht in der
Kopfleiste JEDER Seite UND IN DER BEGRUESSUNG". Die Absicht war
aufgeschrieben, die Haelfte nie gebaut. */
await a1.seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await a1.seite.waitForTimeout(1500);
const gruss = await a1.seite.evaluate(() => {
const p = document.getElementById("plakette");
const b = p?.querySelector("img");
if (!b) return { bild: false, text: p?.textContent || "" };
const r = b.getBoundingClientRect();
const pr = p.getBoundingClientRect();
const st = getComputedStyle(b);
const ps = getComputedStyle(p);
return {
bild: true,
geladen: b.complete && b.naturalWidth > 0,
/* Fuellt es die Plakette wirklich aus? Ein Bild, das nur zu einem
Viertel drinsteht, sieht aus wie ein Fehler. */
fuellt: r.width >= pr.width - 2 && r.height >= pr.height - 2,
/* Rund beschnitten -- ohne overflow:hidden ragt ein quadratisches
Bild aus dem runden Rahmen. */
beschnitten: ps.overflow === "hidden",
passung: st.objectFit,
kein_text: (p.textContent || "").trim() === "",
};
});
ok(gruss.bild === true, "die Begruessung zeigt das Bild statt des Buchstabens");
ok(gruss.geladen === true, " und es ist wirklich geladen (nicht nur ein leerer Rahmen)");
ok(gruss.fuellt === true, " es fuellt die Plakette aus");
ok(gruss.beschnitten === true, " und ist rund beschnitten (overflow: hidden)");
ok(gruss.passung === "cover", ` ohne zu verzerren (object-fit: ${gruss.passung})`);
ok(gruss.kein_text === true, " der Buchstabe steht nicht mehr daneben");
/* ================================================================
2. Das Team -- alle Rollen, mit allem, was sie eingetragen haben
================================================================ */
console.log("\n=== Das Team (Screen 2)");
await a1.seite.goto(BASIS + "/workspace/steckbrief.html", { waitUntil: "networkidle" });
await a1.seite.waitForTimeout(1600);
const team = await a1.seite.evaluate(() => {
const block = document.getElementById("team-block");
const karten = [...document.querySelectorAll(".team-karte")];
const sichtbar = (e) => {
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
return {
da: block && !block.hidden,
/* IST DER STIL ANGEKOMMEN? Die .block__*-Klassen stehen in
automation.css und wissen.css -- diese Seite laedt beide nicht.
Genau so sind hier schon zweimal Stile ins Leere gelaufen.
Gemessen wird deshalb der tatsaechliche Stil, nicht die Klasse. */
/* SEIT DEM 07.09.2026 IST DER RAND KEINE `border` MEHR, sondern
eine Silhouette mit abgeschnittener Ecke aus module.css. Die
alte Bedingung (border >= 1) wurde dadurch zwangsläufig rot,
ohne dass ein Stil fehlte. Die FRAGE bleibt aber genau dieselbe
und ist so wichtig wie zuvor: Ist der Stil auf DIESER Seite
angekommen? Gemessen wird sie jetzt an der neuen Bauweise --
Polsterung wie bisher, dazu die Silhouette, die es nur gibt,
wenn module.css wirklich geladen wurde. */
gestaltet: karten[0] ? (() => {
const st = getComputedStyle(karten[0]);
return parseFloat(st.paddingLeft) >= 8 && /polygon/.test(st.clipPath);
})() : null,
anzahl: karten.length,
sichtbare: karten.filter(sichtbar).length,
gruppen: [...document.querySelectorAll(".team-gruppe__name")].map((e) => e.textContent),
leute: karten.map((k) => ({
name: k.querySelector(".team-karte__name")?.textContent,
rolle: k.dataset.rolle,
rollenname: k.querySelector(".team-karte__rolle")?.textContent,
ueber: k.querySelector(".team-karte__ueber")?.textContent || null,
bild: !!k.querySelector(".team-karte__bild img"),
buchstabe: k.querySelector(".team-karte__bild img")
? null : (k.querySelector(".team-karte__bild")?.textContent || "").trim(),
kanaele: [...k.querySelectorAll(".team-kanal")].map((a) => a.getAttribute("href")),
ich: k.dataset.ich === "ja",
})),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
ok(team.da === true, "der Team-Block erscheint");
ok(team.gestaltet === true, " und die Stile sind angekommen (Rand und Polsterung gemessen)");
ok(team.anzahl === 4 && team.sichtbare === 4,
`alle vier Personen stehen da (${team.anzahl}, davon ${team.sichtbare} sichtbar)`);
ok(team.gruppen.join(", ") === "Management, Scouting, Creator",
`nach Rollen gruppiert: ${team.gruppen.join(", ")}`);
const dogi = team.leute.find((l) => l.name === "Filipe");
const mara = team.leute.find((l) => l.name === "Mara");
const pat = team.leute.find((l) => l.name === "Patrick");
const tili = team.leute.find((l) => l.name === "Tili");
ok(!!dogi && !!mara && !!pat && !!tili,
"Manager, Scout, DogFather UND Creator sind dabei – nicht nur Creator");
ok(dogi?.bild === true, "DogFather wird mit seinem Foto gezeigt");
ok(dogi?.ueber === "Manager & CEO dieser Seite",
` und mit dem, was er ueber sich geschrieben hat ("${dogi?.ueber}")`);
ok(dogi?.ich === true, " seine eigene Karte ist als solche gekennzeichnet");
ok(/tiktok\.com/.test(dogi?.kanaele?.[0] || ""),
` sein Kanal fuehrt wirklich hin (${dogi?.kanaele?.[0]})`);
ok(mara?.ueber === "Ich kuemmere mich um die Vertraege.", "der Manager-Text kommt an");
ok(pat?.ueber === "Ich finde neue Leute.", "der Scout-Text kommt an");
ok(mara?.rollenname === "Manager" && pat?.rollenname === "Scout",
`die Rolle steht dabei (${mara?.rollenname}, ${pat?.rollenname})`);
/* GEGENPROBEN -- ohne sie waere oben alles auch dann gruen, wenn die
Seite einfach ueberall Bilder und Texte hinschriebe. */
ok(mara?.bild === false && mara?.buchstabe === "M",
`Gegenprobe: wer kein Foto hat, bekommt den Buchstaben ("${mara?.buchstabe}")`);
ok(tili?.ueber === null,
"Gegenprobe: wer nichts ueber sich geschrieben hat, bekommt keinen Platzhaltersatz");
ok(tili?.kanaele?.length === 0,
"Gegenprobe: ohne Kanal steht dort auch keiner");
ok(mara?.ich === false, "Gegenprobe: fremde Karten sind nicht als 'du' markiert");
ok(team.ueber <= 0, `nichts steht seitlich ueber (${team.ueber}px)`);
ok(a1.konsole.length === 0,
`keine Fehler in der Konsole${a1.konsole.length ? " -> " + a1.konsole.join(" | ") : ""}`);
await a1.seite.screenshot({ path: "pruef-team.png", fullPage: false });
await a1.kontext.close();
/* ================================================================
3. Ein Creator sieht seine Betreuung -- aber nicht die anderen Creator
================================================================ */
console.log("\n=== Was ein Creator davon sieht");
const a2 = await anmelden("creator", "CODE-ANNA-0001");
await a2.seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
await a2.seite.waitForTimeout(1600);
const sicht = await a2.seite.evaluate(() => ({
da: !document.getElementById("team-block")?.hidden,
namen: [...document.querySelectorAll(".team-karte__name")].map((e) => e.textContent).sort(),
}));
ok(sicht.da === true, "ein Creator sieht das Team auf seiner Profilseite");
ok(sicht.namen.includes("Patrick"), " seinen Scout");
ok(sicht.namen.includes("Filipe") && sicht.namen.includes("Mara"),
" und das Management");
ok(a2.konsole.length === 0,
`keine Fehler in der Konsole${a2.konsole.length ? " -> " + a2.konsole.join(" | ") : ""}`);
await a2.kontext.close();
/* Ein zweiter Creator, um zu pruefen, dass Creator einander NICHT sehen.
Das ist kein Schoenheitsfehler: Der Server entscheidet das, und wenn
sich die Regel je aendert, muss es hier auffallen. */
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-LUNA-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d2.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run("Luna", "creator", hash, salt, 32768, jetzt);
d2.close();
const a3 = await anmelden("creator", "CODE-ANNA-0001");
await a3.seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
await a3.seite.waitForTimeout(1600);
const fremd = await a3.seite.evaluate(() =>
[...document.querySelectorAll(".team-karte__name")].map((e) => e.textContent));
ok(!fremd.includes("Luna"),
`Gegenprobe: ein Creator sieht einen fremden Creator NICHT (${fremd.join(", ")})`);
await a3.kontext.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
process.exit(fehler ? 1 : 0); process.exit(fehler ? 1 : 0);
+173
View File
@@ -0,0 +1,173 @@
/* WAS DARF LIEGENBLEIBEN, UND WAS NICHT (09.09.2026)
DER FALL, DER DAS AUSGELÖST HAT: Filipe meldete, eine Änderung an der
Kopfleiste sei "nicht gemacht". Gemessen war sie auf allen 18 Seiten,
in allen fünf Rollen, bei drei Breiten in Ordnung -- und die neuen
Dateien lagen nachweislich auf dem Server. Er sah trotzdem den alten
Stand.
Die Ursache: Die HTML-Seiten gingen ohne jede Cache-Angabe hinaus.
Ohne `Cache-Control` und ohne `Expires` darf ein Browser heuristisch
zwischenspeichern. Und in der HTML stehen die VERSIONSSTEMPEL der
Stilvorlagen -- eine alte HTML holt also auch alte CSS- und
JS-Dateien. Der ganze Stempelmechanismus hing an einer Datei, die
selbst liegenbleiben durfte.
DIESE PRÜFUNG IST DER GRUND, WARUM ES NICHT WIEDERKOMMT. Sie fragt
echte Adressen ab und liest die Kopfzeilen. Eine Regel im Quelltext
zu lesen wäre wertlos: Zwischen `setHeaders` und dem, was ankommt,
liegen Express, Caddy und Cloudflare.
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-cache-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4343, "die Zwischenspeicher-Pruefung");
process.env.PORT = "4343";
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(120_000, "pruef-zwischenspeicher");
await new Promise((r) => setTimeout(r, 900));
const BASIS = "http://127.0.0.1:4343";
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ANGEMELDET MESSEN, SONST MISST MAN EINE UMLEITUNG.
Der erste Anlauf fragte die Workspace-Seiten ohne Anmeldung ab und
meldete "keine Cache-Angabe" fuer drei von ihnen -- der Server hatte
aber gar keine Seite geschickt, sondern eine Umleitung zur Anmeldung,
und eine Umleitung traegt zu Recht keine.
Das ist genau die Sorte Befund, die teuer ist: Sie sieht aus wie ein
Fehler in der Sache und ist einer in der Messung. Die Pruefung
braucht ihre Voraussetzung, bevor sie misst. */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try { const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", 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("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
d.close();
}
const anAntwort = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }) });
const KEKS = (anAntwort.headers.getSetCookie?.() || []).map((x) => x.split(";")[0]).join("; ");
if (!KEKS) {
console.error("\nKONNTE NICHT ARBEITEN: keine Anmeldung, also keine Seiten zu messen.");
process.exit(2);
}
const kopf = async (pfad) => {
const a = await fetch(BASIS + pfad, { redirect: "manual", headers: { cookie: KEKS } });
return { status: a.status, cc: a.headers.get("cache-control") };
};
console.log("\n=== HTML wird immer nachgefragt");
for (const p of ["/workspace/start.html", "/workspace/aufgaben.html",
"/workspace/kalender.html", "/index.html"]) {
const r = await kopf(p);
/* DIE NUMMER GEHOERT IN DIE BEDINGUNG. Eine Umleitung traegt zu Recht
keine Cache-Angabe -- wer sie mitmisst, sucht einen Fehler, den es
nicht gibt. */
ok(r.status === 200 && /no-cache|no-store|max-age=0/.test(r.cc || ""),
`${p}: HTTP ${r.status}, ${r.cc || "(keine Angabe)"}`);
}
console.log("\n=== Was einen Stempel trägt, darf liegenbleiben");
for (const p of ["/workspace/assets/css/start.css", "/workspace/assets/js/kopf.js",
"/workspace/assets/css/module.css"]) {
const r = await kopf(p);
ok(/max-age=\d{6,}/.test(r.cc || ""), `${p}: ${r.cc || "(keine Angabe)"}`);
}
console.log("\n=== Der Service Worker NICHT");
{
const r = await kopf("/workspace/sw.js");
ok(/no-cache/.test(r.cc || ""),
`sw.js wird ohne Stempel geladen und muss nachgefragt werden: ${r.cc || "(keine)"}`);
}
console.log("\n=== Bilder: ein Tag, nicht ein Jahr");
{
const r = await kopf("/workspace/assets/img/marke-husky.webp");
ok(r.status === 404 || /max-age=86400/.test(r.cc || ""),
`Bild ohne Stempel: ${r.cc || "(keine Angabe)"} (HTTP ${r.status})`);
}
/* ---------------------------------------------------------------------
GEGENPROBEN
1. Die Prüfung muss eine FEHLENDE Angabe erkennen -- sonst hätte sie
genau den Zustand durchgewinkt, der den Fehler verursacht hat.
2. Und sie darf nicht alles durchwinken: "no-cache" bei einer
Stilvorlage wäre auch falsch (jeder Aufruf eine Anfrage).
--------------------------------------------------------------------- */
console.log("\n=== Gegenproben");
{
const pruefHtml = (cc) => /no-cache|no-store|max-age=0/.test(cc || "");
ok(!pruefHtml(null), "eine fehlende Angabe fällt durch");
ok(!pruefHtml("public, max-age=31536000"), "und ein langer Speicher an einer HTML ebenfalls");
const pruefFest = (cc) => /max-age=\d{6,}/.test(cc || "");
ok(!pruefFest("no-cache"), "eine Stilvorlage ohne Speicher fällt ebenfalls auf");
ok(pruefFest("public, max-age=31536000, immutable"), "und die richtige Angabe besteht");
}
/* ---------------------------------------------------------------------
UND DER STEMPEL SELBST: Jede Stilvorlage und jedes Skript, das eine
HTML einbindet, MUSS einen tragen. Ohne ihn wäre ein Jahr Speicher
ein Jahr alter Stand -- die Regel oben und diese hier gehören
zusammen, sonst ist die eine ohne die andere gefährlich.
--------------------------------------------------------------------- */
console.log("\n=== Jede eingebundene Datei trägt einen Stempel");
{
const { readdirSync, readFileSync } = await import("node:fs");
const { dirname } = await import("node:path");
const { fileURLToPath } = await import("node:url");
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace");
const seiten = readdirSync(WURZEL).filter((n) => n.endsWith(".html"));
const ohne = [];
let mit = 0;
for (const s of seiten) {
const text = readFileSync(join(WURZEL, s), "utf8");
for (const t of text.matchAll(/(?:href|src)="(assets\/[^"]*\.(?:css|js|mjs))(\?[^"]*)?"/g)) {
if (t[2] && /\?v=\d+/.test(t[2])) mit++;
else ohne.push(`${s} → ${t[1]}`);
}
}
ok(mit > 100, `${mit} eingebundene Dateien tragen einen Stempel`);
ok(ohne.length === 0,
ohne.length ? `OHNE Stempel: ${ohne.slice(0, 4).join(", ")}`
: `und keine einzige ohne (${seiten.length} Seiten geprüft)`);
}
console.log(`\n${geprueft} Prüfungen`);
console.log(fehler ? `${fehler} FEHLER` : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(fehler ? 1 : 0);
+319
View File
@@ -0,0 +1,319 @@
/* =====================================================================
workspace-team.js — die Arbeitslage von Managern und Scouts.
Wunsch Filipe, 09.09.2026: *"so eine kategorie wie über die creator
will ich dass nur für die spicy und dogfather rolle auch über manager
und scouts gibt ... ich will dass es so ultra krass gut ist dass die
spicy und dogfather rolle einen kompletten teil haben mit daten über
die arbeit von den manager und scout. keine geheimen sachen also
termine, chats und geheime dateien soll auch so bleiben dass keiner."*
---------------------------------------------------------------------
DREI ENTSCHEIDUNGEN, DIE ALLES ANDERE TRAGEN
1. WAS HIER NICHT VORKOMMT, UND ZWAR GAR NICHT.
Keine Termine, keine Chats, keine Dateien -- weder Inhalte noch
Zählungen. Ausdrücklicher Wunsch, und er ist auch der richtige:
Ein Kalender verrät, wann jemand nicht da war; ein Chatzähler
verrät, mit wem jemand oft spricht. Beides ist Privatsache und
beantwortet keine Frage, die eine Agentur wirklich hat.
Die Abfragen unten rühren `termine`, `chat_*` und `dateien` nicht
an. Das ist keine Zusicherung im Kommentar, sondern eine
Eigenschaft des Codes -- pruef-team.mjs liest diese Datei und
schlägt an, wenn eine dieser Tabellen darin auftaucht.
2. SEGMENTIEREN, NICHT MITTELN.
Aus der Recherche: Ein Durchschnitt versteckt genau die Person,
bei der es klemmt. Zwei Scouts mit je 6 Creators und einer mit 18
ergeben einen Schnitt von 10 -- und der sagt nichts. Deshalb wird
hier JEDE Person einzeln ausgegeben, und die Oberfläche markiert
die Ausreißer.
3. ES IST EINE ARBEITSLAGE, KEINE ÜBERWACHUNG.
Auch das steht so in der Fachliteratur: Der Zweck ist, Stellen zu
finden, an denen Unterstützung fehlt -- nicht, Leute zu messen.
Deshalb sind die Kennzahlen so gewählt, dass sie auf ZUSTÄNDE
zeigen (unbetreute Creator, überfällige Aufgaben, liegengebliebene
Kontakte) und nicht auf Anwesenheit oder Fleiß.
---------------------------------------------------------------------
WER DARF HINEIN
Nur `spicy` und `admin`. Und Spicy Media sieht DogFathers zweiten
Zugang nicht -- dieselbe zentrale Regel wie überall
(`verborgeneIds`), nicht eine eigene Fassung davon.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, verborgeneIds, heuteLokal, ROLLEN_SORTIERUNG,
} from "./workspace.js";
export const teamRouter = express.Router();
/* Wer diesen ganzen Bereich sehen darf. EINE Menge, an EINER Stelle --
die Seite fragt dieselbe über /workspace/api/ich ab. */
export const TEAM_ROLLEN = new Set(["spicy", "admin"]);
function nurLeitung(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* 404 und nicht 403: Was man nicht sehen darf, soll es fuer den
Fragenden gar nicht geben. Ein 403 bestaetigt, dass es den Bereich
gibt -- und genau das ist die Auskunft, die man nicht geben will. */
if (!TEAM_ROLLEN.has(person.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" });
req.person = person;
next();
}
teamRouter.use("/workspace/api/team", nurLeitung);
const zahl = (x) => Number(x) || 0;
/* Tage zwischen zwei Zeitstempeln, auf eine Stelle gerundet. */
function tageZwischen(a, b) {
if (!a || !b) return null;
const d = (new Date(b) - new Date(a)) / 86400000;
return Number.isFinite(d) && d >= 0 ? Math.round(d * 10) / 10 : null;
}
/* =====================================================================
DIE ARBEITSLAGE
===================================================================== */
teamRouter.get("/workspace/api/team", (req, res) => {
try {
const d = db();
const heute = heuteLokal();
const weg = new Set(verborgeneIds(req.person));
/* ---- 1. Wer ist überhaupt da ---------------------------------- */
const leute = d.prepare(`
SELECT id, name, rolle, aktiv, erstellt, letzter_login, bild
FROM personen
WHERE rolle IN ('manager','scout')
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all()
.filter((p) => !weg.has(p.id));
/* ---- 2. Wen betreut wer --------------------------------------- */
/* EIN Creator kann nur EINEN Betreuer haben (betreuung ist so
gebaut). Ein Manager bekommt zusätzlich die Creator, die an
seinen Scouts hängen -- das ist seine mittelbare Verantwortung,
und ohne sie sähe ein Manager mit fünf Scouts aus wie jemand
ohne Arbeit. */
const betreut = d.prepare(`
SELECT b.betreuer_id AS wer, p.id AS creator_id, p.name AS creator, p.aktiv
FROM betreuung b JOIN personen p ON p.id = b.creator_id`).all();
const scoutsVon = d.prepare(`
SELECT z.manager_id AS wer, p.id AS scout_id, p.name AS scout
FROM scout_zuteilung z JOIN personen p ON p.id = z.scout_id`).all();
const direkt = new Map(); /* betreuer_id -> [creator] */
for (const z of betreut) {
if (!direkt.has(z.wer)) direkt.set(z.wer, []);
direkt.get(z.wer).push({ id: z.creator_id, name: z.creator, aktiv: !!z.aktiv });
}
const meineScouts = new Map(); /* manager_id -> [scout] */
for (const z of scoutsVon) {
if (!meineScouts.has(z.wer)) meineScouts.set(z.wer, []);
meineScouts.get(z.wer).push({ id: z.scout_id, name: z.scout });
}
/* ---- 3. Aufgaben, nach Creator gruppiert ---------------------- */
/* Eine Abfrage fuer alle -- nicht eine je Person. Bei zwoelf Leuten
waeren das zwoelf Abfragen fuer dieselbe Tabelle. */
const aufgabenJeCreator = new Map();
for (const z of d.prepare(`
SELECT creator_id,
SUM(CASE WHEN status IN ('offen','arbeit','review') THEN 1 ELSE 0 END) AS laufend,
SUM(CASE WHEN status IN ('offen','arbeit','review')
AND frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS ueberfaellig,
SUM(CASE WHEN status = 'erledigt' THEN 1 ELSE 0 END) AS erledigt,
SUM(CASE WHEN status = 'abgebrochen' THEN 1 ELSE 0 END) AS abgebrochen,
SUM(CASE WHEN status = 'erledigt' AND erledigt_am >= ? THEN 1 ELSE 0 END) AS erledigt30
FROM aufgaben WHERE creator_id IS NOT NULL
GROUP BY creator_id`).all(heute, vorTagen(30))) {
aufgabenJeCreator.set(z.creator_id, z);
}
/* Durchlaufzeit: von der Anlage bis zum Abhaken. Nur die letzten
neunzig Tage -- eine Zahl aus dem letzten Jahr beschreibt eine
Arbeitsweise, die es vielleicht nicht mehr gibt. */
const durchlauf = new Map();
for (const z of d.prepare(`
SELECT creator_id, erstellt, erledigt_am FROM aufgaben
WHERE status = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am >= ?`)
.all(vorTagen(90))) {
const t = tageZwischen(z.erstellt, z.erledigt_am);
if (t === null) continue;
if (!durchlauf.has(z.creator_id)) durchlauf.set(z.creator_id, []);
durchlauf.get(z.creator_id).push(t);
}
/* ---- 4. Fortschritt der betreuten Creator --------------------- */
const startcheckJeCreator = new Map();
for (const z of d.prepare(`
SELECT creator_id, COUNT(*) AS gesamt,
SUM(CASE WHEN bewertung IS NOT NULL AND bewertung <> '' AND bewertung <> 'offen'
THEN 1 ELSE 0 END) AS erledigt
FROM startcheck GROUP BY creator_id`).all()) {
startcheckJeCreator.set(z.creator_id, z);
}
const punkteJeCreator = new Map();
for (const z of d.prepare(`
SELECT creator_id, COUNT(*) AS gesamt,
SUM(CASE WHEN status = 'offen' THEN 1 ELSE 0 END) AS offen
FROM eintraege WHERE creator_id IS NOT NULL GROUP BY creator_id`).all()) {
punkteJeCreator.set(z.creator_id, z);
}
/* ---- 5. Zahlen der betreuten Creator (30 Tage) ---------------- */
const leistungJeCreator = new Map();
for (const z of d.prepare(`
SELECT creator_id, SUM(diamanten) AS diamanten, SUM(dauer_min) AS minuten,
COUNT(*) AS tage, MAX(tag) AS letzter
FROM leistung WHERE tag >= ? GROUP BY creator_id`).all(vorTagen(30))) {
leistungJeCreator.set(z.creator_id, z);
}
/* ---- 6. Die Pipeline der Scouts ------------------------------- */
const leadsJeScout = new Map();
for (const z of d.prepare(`
SELECT scout_id, COUNT(*) AS gesamt,
/* DIE STATUS STEHEN IN DER TABELLE, NICHT IN MEINEM KOPF.
Erster Anlauf war ('neu','kontakt','gespraech') -- "kontakt"
gibt es gar nicht, und die Datenbank hat es mit einer
CHECK-Bedingung sofort abgelehnt. Gut so: Ohne die
Bedingung waere "offen" still zu klein gewesen, und
niemand haette es gemerkt. */
SUM(CASE WHEN status IN ('neu','angesprochen','gespraech','interessiert')
THEN 1 ELSE 0 END) AS offen,
SUM(CASE WHEN status = 'uebergeben' THEN 1 ELSE 0 END) AS uebergeben,
SUM(CASE WHEN status = 'abgelehnt' THEN 1 ELSE 0 END) AS abgelehnt,
SUM(CASE WHEN naechster_followup IS NOT NULL AND naechster_followup < ?
AND status NOT IN ('uebergeben','abgelehnt') THEN 1 ELSE 0 END) AS faellig
FROM leads WHERE scout_id IS NOT NULL GROUP BY scout_id`).all(heute)) {
leadsJeScout.set(z.scout_id, z);
}
/* Wie lange von der Anlage bis zur Übergabe? Das ist die Zahl, die
eine Pipeline wirklich beschreibt -- eine Menge offener Kontakte
sagt nichts, wenn keiner davon je ankommt. */
const uebergabeJeScout = new Map();
for (const z of d.prepare(`
SELECT scout_id, erstellt, uebergeben_am FROM leads
WHERE uebergeben_am IS NOT NULL AND scout_id IS NOT NULL`).all()) {
const t = tageZwischen(z.erstellt, z.uebergeben_am);
if (t === null) continue;
if (!uebergabeJeScout.has(z.scout_id)) uebergabeJeScout.set(z.scout_id, []);
uebergabeJeScout.get(z.scout_id).push(t);
}
/* ---- 7. Alles zu einer Zeile je Person zusammensetzen --------- */
const mittel = (liste) => (liste && liste.length
? Math.round((liste.reduce((s, x) => s + x, 0) / liste.length) * 10) / 10 : null);
const zeilen = leute.map((p) => {
const eigene = direkt.get(p.id) || [];
const scouts = meineScouts.get(p.id) || [];
/* Mittelbare Creator: die der eigenen Scouts. Ohne Doppelung,
falls ein Manager einen Creator auch direkt betreut. */
const mittelbar = [];
const schon = new Set(eigene.map((c) => c.id));
for (const s of scouts) {
for (const c of direkt.get(s.id) || []) {
if (!schon.has(c.id)) { schon.add(c.id); mittelbar.push(c); }
}
}
const alleCreator = [...eigene, ...mittelbar];
const summe = (karte, feld) => alleCreator
.reduce((s, c) => s + zahl((karte.get(c.id) || {})[feld]), 0);
const zeiten = alleCreator.flatMap((c) => durchlauf.get(c.id) || []);
const scGesamt = summe(startcheckJeCreator, "gesamt");
const scFertig = summe(startcheckJeCreator, "erledigt");
const lead = leadsJeScout.get(p.id) || {};
return {
id: p.id, name: p.name, rolle: p.rolle, aktiv: !!p.aktiv,
seit: p.erstellt, letzter_login: p.letzter_login,
bild: p.bild ? `/workspace/api/steckbrief/bild/${p.bild}` : null,
creator: eigene.map((c) => ({ id: c.id, name: c.name, aktiv: c.aktiv })),
creator_mittelbar: mittelbar.length,
scouts: scouts.map((s) => s.name),
aufgaben: {
laufend: summe(aufgabenJeCreator, "laufend"),
ueberfaellig: summe(aufgabenJeCreator, "ueberfaellig"),
erledigt30: summe(aufgabenJeCreator, "erledigt30"),
abgebrochen: summe(aufgabenJeCreator, "abgebrochen"),
durchlauf_tage: mittel(zeiten),
},
fortschritt: {
startcheck_gesamt: scGesamt,
startcheck_fertig: scFertig,
punkte_gesamt: summe(punkteJeCreator, "gesamt"),
punkte_offen: summe(punkteJeCreator, "offen"),
},
zahlen: {
diamanten30: summe(leistungJeCreator, "diamanten"),
minuten30: summe(leistungJeCreator, "minuten"),
livetage30: summe(leistungJeCreator, "tage"),
},
pipeline: p.rolle === "scout" ? {
gesamt: zahl(lead.gesamt), offen: zahl(lead.offen),
uebergeben: zahl(lead.uebergeben), abgelehnt: zahl(lead.abgelehnt),
faellig: zahl(lead.faellig),
uebergabe_tage: mittel(uebergabeJeScout.get(p.id)),
} : null,
};
});
/* ---- 8. Die Lücken ------------------------------------------- */
/* Das ist der wichtigste Teil der ganzen Seite. Eine Kennzahl sagt,
wie es läuft; eine Lücke sagt, wo etwas fehlt -- und nur das
Zweite kann man heute abstellen. */
const alleBetreut = new Set(betreut.map((z) => z.creator_id));
const ohneBetreuung = d.prepare(`
SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name`)
.all().filter((c) => !alleBetreut.has(c.id) && !weg.has(c.id));
const zugeteilteScouts = new Set(scoutsVon.map((z) => z.scout_id));
const scoutsOhneManager = leute
.filter((p) => p.rolle === "scout" && p.aktiv && !zugeteilteScouts.has(p.id))
.map((p) => ({ id: p.id, name: p.name }));
const ohneCreator = zeilen
.filter((z) => z.aktiv && !z.creator.length && !z.creator_mittelbar)
.map((z) => ({ id: z.id, name: z.name, rolle: z.rolle }));
res.json({
stand: heute,
leute: zeilen,
luecken: {
creator_ohne_betreuung: ohneBetreuung,
scouts_ohne_manager: scoutsOhneManager,
ohne_zuteilung: ohneCreator,
},
/* Was hier ausdruecklich NICHT drinsteht -- die Oberflaeche sagt
es dem Betrachter, damit niemand danach sucht. */
ausgenommen: ["Termine", "Chats", "Dateien"],
});
} catch (fehler) {
console.error("[workspace] Team-Lage:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ein Datum vor N Tagen, in Ortszeit als "JJJJ-MM-TT".
* NICHT toISOString(): Das liefert UTC und damit nachts den Vortag --
* derselbe Fehler, der im Haus schon zweimal eine Zahl verschoben hat. */
function vorTagen(n) {
const d = new Date(Date.now() - n * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
}
+13
View File
@@ -1982,6 +1982,19 @@ const GESCHUETZT = {
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator"], "/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"], "/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"], "/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
Filipe: "so eine kategorie wie ueber die creator will ich dass nur
fuer die spicy und dogfather rolle auch ueber manager und scouts
gibt."
Nur diese beiden Rollen. Ein Manager sieht seine eigene Zeile hier
ausdruecklich NICHT -- das ist kein Versehen: Eine Uebersicht ueber
die Arbeit der Kollegen gehoert der Leitung, nicht der Reihe. Der
Schutz steht ohnehin in der Schnittstelle (workspace-team.js);
diese Zeile sorgt nur dafuer, dass niemand vor einer leeren Seite
steht. */
"/workspace/team.html": ["spicy", "admin"],
"/workspace/calls.html": null, "/workspace/calls.html": null,
"/workspace/startcheck.html": null, "/workspace/startcheck.html": null,
"/workspace/wissen.html": null, "/workspace/wissen.html": null,
+41
View File
@@ -912,6 +912,23 @@ body.start > main { position: relative; z-index: 1; }
Kontrast -- deshalb dieser und nicht der erstbeste. */ Kontrast -- deshalb dieser und nicht der erstbeste. */
[data-ton="21"] { --ton: #ff1f2e; } /* Scout-Pipeline -- Wunsch: knallrot */ [data-ton="21"] { --ton: #ff1f2e; } /* Scout-Pipeline -- Wunsch: knallrot */
/* TON 22 -- TEAM-LAGE (09.09.2026).
Die neue Gruppe "Rund um das Team" braucht einen eigenen Ton. Nicht
gewaehlt, sondern gerechnet: Von elf Kandidaten hat #ffd166 den
groessten Abstand zu allen einundzwanzig vorhandenen Toenen --
0,1165 in OKLab zu seinem naechsten Nachbarn (#afde57). Die
Hausgrenze ist das engste vorhandene Paar, und das liegt bei 0,0973;
der neue Ton ist also klarer unterscheidbar als das, was ohnehin
schon nebeneinander steht.
Kontrast auf dem dunklen Grund: 13,91:1.
Warm und hell ist kein Zufall: Diese eine Kachel gehoert der
Leitung. Zwischen den kuehlen Toenen der Arbeitsbereiche faellt ein
warmer auf, ohne lauter zu sein. */
[data-ton="22"] { --ton: #ffd166; } /* Team-Lage */
/* 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
Regeln als NICHT vorhanden und die Nummer oben gewinnt immer. Ohne Regeln als NICHT vorhanden und die Nummer oben gewinnt immer. Ohne
@@ -3754,6 +3771,30 @@ body[data-fremde-sicht="ja"]::after {
300 px und nicht ganz weg: Darunter gibt es Geraete, auf denen es 300 px und nicht ganz weg: Darunter gibt es Geraete, auf denen es
wirklich nicht reicht, und dort ist die zweite Zeile besser als ein wirklich nicht reicht, und dort ist die zweite Zeile besser als ein
Ueberstand. Gemessen bei 360, 390, 412 und 430 -- alle einzeilig. */ Ueberstand. Gemessen bei 360, 390, 412 und 430 -- alle einzeilig. */
/* NOCH EINE STUFE ENGER (09.09.2026, gemeldet von pruef-handy).
Bei 320 px hat die Leiste doch umgebrochen -- und zwar nur auf
Unterseiten. Dort steht links der Zurueck-Knopf (55 px), rechts
kommt auf manchen Seiten die Glocke als siebtes Element dazu.
Gerechnet: 55 + 6 + (5 x 36 + 28 + 6 x 6) = 305, verfuegbar 294.
Elf Pixel. Statt die Schwelle wieder hochzusetzen -- was die zweite
Zeile zurueckgebracht haette -- wird hier eine Stufe kleiner
geschaltet: 34 px je Knopf, 4 px Abstand. Macht 283 und passt.
34 px bleiben deutlich ueber den 24 px, die als Mindestmass fuer ein
Beruehrziel gelten (WCAG 2.5.8). */
@media (max-width: 340px) {
.kopfleiste__rechts { gap: 4px; }
.kopfleiste__rechts > .teilen,
.kopfleiste__rechts > .chat-knopf,
.kopfleiste__rechts > .suche-knopf,
.kopfleiste__rechts > .abmelden,
.kopfleiste__rechts > .glocke,
.sicht:not([data-fremd="ja"]) {
width: 34px; min-width: 34px; height: 34px; min-height: 34px;
}
}
@media (max-width: 300px) { @media (max-width: 300px) {
.kopfleiste { flex-wrap: wrap; row-gap: 8px; } .kopfleiste { flex-wrap: wrap; row-gap: 8px; }
/* Die Marke nimmt die erste Zeile ganz ein, die Bedienelemente /* Die Marke nimmt die erste Zeile ganz ein, die Bedienelemente
+245
View File
@@ -0,0 +1,245 @@
/* =====================================================================
team.css — die Arbeitslage von Managern und Scouts.
Wunsch Filipe, 09.09.2026: "so eine kategorie wie über die creator
will ich dass nur für die spicy und dogfather rolle auch über manager
und scouts gibt ... ich will dass es so ultra krass gut ist."
DIE GESTALTUNG FOLGT EINER ENTSCHEIDUNG, NICHT EINEM GESCHMACK:
Diese Seite zeigt Zahlen über Kollegen. Sie darf deshalb NICHT
aussehen wie ein Cockpit mit Warnleuchten. Rot gibt es genau dort,
wo etwas liegengeblieben ist -- und sonst nirgends. Alles andere ist
ruhig, damit die wenigen roten Stellen auch etwas heißen.
===================================================================== */
/* ---------- Der Kopf --------------------------------------------------- */
.t-kopf {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 20px; flex-wrap: wrap;
margin: 0 0 18px;
}
.t-kopf__titel {
margin: 4px 0 6px;
font-size: clamp(1.5rem, 3.4vw, 2.1rem); font-weight: 700;
color: var(--text);
letter-spacing: -.01em;
}
.t-kopf__unter { margin: 0; font-size: .96rem; color: var(--text-leise); }
/* Der Satz über die Grenzen steht in einem eigenen Kasten und nicht als
Fußnote. Er ist die wichtigste Zeile der Seite: Ohne ihn wird eine
Arbeitslage als Überwachung gelesen -- und dann arbeitet niemand mehr
offen damit. */
.t-kopf__grenze {
flex: 1 1 300px; max-width: 44ch;
margin: 0; padding: 12px 15px;
border-left: 3px solid color-mix(in srgb, var(--scout-haupt) 55%, transparent);
border-radius: 0 10px 10px 0;
background: rgba(95, 201, 154, .07);
font-size: .87rem; line-height: 1.55; color: var(--text-leise);
}
.t-kopf__grenze strong { color: var(--text); }
/* ---------- Die Lücken ------------------------------------------------- */
.t-luecken {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
margin: 0 0 18px;
}
/* EINE UNDURCHSICHTIGE FLAECHE, KEIN SCHLEIER.
Die Lektion vom Vorlagenbrett heute Mittag, hier gleich mitgenommen:
Eine Karte mit 7 Prozent Farbe auf transparentem Grund laesst das
Buehnenfoto durchscheinen -- und dann haengt die Lesbarkeit davon ab,
ob hinter dem Text gerade eine helle oder eine dunkle Stelle liegt.
Dort hat es 3,61:1 ergeben, wo 4,5 noetig sind.
Deshalb hier von vornherein ein eigener, deckender Grund. Der rote
Ton liegt darauf, nicht darin. */
.tl {
padding: 15px 17px;
border: 1px solid color-mix(in srgb, var(--warn, #ff9aa2) 34%, transparent);
border-radius: var(--radius-klein);
background:
linear-gradient(rgba(255, 154, 162, .07), rgba(255, 154, 162, .07)),
rgba(7, 10, 17, .93);
backdrop-filter: blur(8px);
}
.tl__titel {
margin: 0 0 4px; font-size: .98rem; font-weight: 700;
color: color-mix(in srgb, var(--warn, #ff9aa2) 78%, #ffffff);
}
.tl__satz { margin: 0 0 8px; font-size: .84rem; color: var(--text-leise); line-height: 1.5; }
.tl__namen {
margin: 0; font-size: .86rem; color: var(--text); line-height: 1.6;
overflow-wrap: anywhere;
}
/* Keine Lücke ist auch eine Auskunft -- und dann grün statt rot. */
.tl[data-gut="ja"] {
border-color: color-mix(in srgb, var(--scout-haupt) 34%, transparent);
background:
linear-gradient(rgba(95, 201, 154, .07), rgba(95, 201, 154, .07)),
rgba(7, 10, 17, .93);
}
.tl[data-gut="ja"] .tl__titel { color: var(--scout-haupt); }
/* ---------- Die Lage in Zahlen ----------------------------------------- */
.t-lage {
display: grid; gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
margin: 0 0 22px;
}
/* ---------- Eine Kennzahl ---------------------------------------------- */
.tz {
display: grid; gap: 2px; align-content: start;
padding: 11px 13px;
border: 1px solid var(--rand); border-radius: var(--radius-klein);
/* Ebenfalls deckend -- eine Kennzahl auf einem Foto ist keine
Kennzahl, sondern ein Ratespiel. */
background:
linear-gradient(rgba(255, 255, 255, .028), rgba(255, 255, 255, .028)),
rgba(7, 10, 17, .9);
}
.tz__wert {
font-size: 1.28rem; font-weight: 700; color: var(--text);
font-variant-numeric: tabular-nums; line-height: 1.15;
}
.tz__schild {
font-size: .74rem; color: var(--text-leise); line-height: 1.35;
}
/* Rot NUR, wo etwas liegengeblieben ist. */
.tz[data-art="warn"] {
border-color: color-mix(in srgb, var(--warn, #ff9aa2) 42%, transparent);
/* Auch hier deckend. Diese Zeile stand zuerst mit einem einzelnen
rgba-Wert da und hat damit den undurchsichtigen Grund von oben
wieder aufgehoben -- ausgerechnet an den Karten, auf die es
ankommt. Zwei Regeln fuer denselben Grund, und die spaetere
gewinnt: derselbe Fehler wie so oft in diesem Haus. */
background:
linear-gradient(rgba(255, 154, 162, .09), rgba(255, 154, 162, .09)),
rgba(7, 10, 17, .9);
}
.tz[data-art="warn"] .tz__wert { color: color-mix(in srgb, var(--warn, #ff9aa2) 72%, #ffffff); }
/* Und grün, wenn eine Null eine gute Nachricht ist -- eine Null ohne
Farbe liest sich wie ein fehlender Wert. */
.tz[data-art="gut"] { border-color: color-mix(in srgb, var(--scout-haupt) 30%, transparent); }
.tz[data-art="gut"] .tz__wert { color: var(--scout-haupt); }
/* ---------- Die Leute --------------------------------------------------- */
.t-gruppe { margin: 0 0 26px; }
.t-gruppe .feldschild { margin: 0 0 10px; }
.t-liste {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
}
.tperson {
--r: var(--text-still);
position: relative;
padding: 16px 18px 18px;
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
background:
linear-gradient(180deg, color-mix(in srgb, var(--r) 6%, transparent), transparent 60%),
var(--flaeche);
}
.tperson[data-rolle="manager"] { --r: var(--manager-haupt); }
.tperson[data-rolle="scout"] { --r: var(--scout-haupt); }
/* Der Streifen in der Rollenfarbe -- dieselbe Sprache wie in der
Personenliste, damit man beide Seiten als ein Haus liest. */
.tperson::after {
content: ""; position: absolute; left: 0; top: 10px; bottom: 10px;
width: 3px; border-radius: 999px;
background: linear-gradient(180deg,
color-mix(in srgb, var(--r) 80%, transparent),
color-mix(in srgb, var(--r) 25%, transparent));
pointer-events: none;
}
.tperson[data-gesperrt="ja"] { opacity: .6; }
.tperson__kopf {
display: flex; align-items: center; gap: 12px;
margin-bottom: 14px; flex-wrap: wrap;
}
.tperson__bild {
width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
border: 1px solid color-mix(in srgb, var(--r) 40%, transparent);
flex: none;
}
.tperson__zeichen {
width: 44px; height: 44px; border-radius: 50%;
display: grid; place-items: center; flex: none;
font-size: 1.05rem; font-weight: 700;
color: color-mix(in srgb, var(--r) 75%, #ffffff);
background: color-mix(in srgb, var(--r) 16%, #070a11);
border: 1px solid color-mix(in srgb, var(--r) 34%, transparent);
}
.tperson__text { flex: 1; min-width: 0; }
/* LANGE NAMEN BRECHEN UM, statt abgeschnitten zu werden.
"Patrick-Maximilian" braucht 90 px, bei 320 px Bildschirm stehen 56
zur Verfuegung -- gemeldet von pruef-handy. Ein Name, den man nicht
lesen kann, ist auf einer Seite ueber Personen der schlechteste
Platz zum Sparen. */
.tperson__name {
margin: 0; font-size: 1.06rem; font-weight: 650; color: var(--text);
overflow-wrap: anywhere; hyphens: auto;
}
.tperson__unter {
margin: 2px 0 0; font-size: .82rem; color: var(--text-leise); line-height: 1.45;
overflow-wrap: anywhere;
}
.tperson__zuletzt {
flex: none;
padding: 4px 10px; border-radius: 999px;
font-size: .74rem; font-weight: 600;
color: var(--text-leise);
background: rgba(255, 255, 255, .05);
border: 1px solid var(--rand);
}
.tperson__zuletzt[data-warn="ja"] {
color: color-mix(in srgb, var(--warn, #ff9aa2) 72%, #ffffff);
border-color: color-mix(in srgb, var(--warn, #ff9aa2) 40%, transparent);
background: rgba(255, 154, 162, .1);
}
.tperson__zahlen {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr));
}
/* ---------- Die Pipeline ------------------------------------------------ */
.tpipe {
margin-top: 14px; padding-top: 13px;
border-top: 1px solid var(--rand);
}
.tpipe__schild {
margin: 0 0 8px;
font-size: .72rem; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: var(--text-still);
}
.tpipe__reihe {
display: grid; gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(min(120px, 100%), 1fr));
}
/* ---------- Wen genau --------------------------------------------------- */
.tperson__wer { margin-top: 12px; }
.tperson__wer summary {
cursor: pointer; font-size: .82rem; color: var(--text-leise);
padding: 4px 0;
}
.tperson__wer summary:hover { color: var(--text); }
.tperson__wer summary:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.tperson__namen {
margin: 6px 0 0; font-size: .84rem; color: var(--text-leise); line-height: 1.65;
}
@media (max-width: 620px) {
.t-kopf { flex-direction: column; gap: 14px; }
.t-kopf__grenze { max-width: none; }
.tperson { padding: 14px 15px 16px; }
}
@media (forced-colors: active) {
.tz, .tl, .tperson { border: 1px solid CanvasText; }
.tperson::after { background: CanvasText; }
}
+38
View File
@@ -241,6 +241,16 @@
linien: ['M4 19.5h16', 'M6.6 9.5h1.8v7h-1.8z', 'M11.1 5.5h1.8v11h-1.8z', linien: ['M4 19.5h16', 'M6.6 9.5h1.8v7h-1.8z', 'M11.1 5.5h1.8v11h-1.8z',
'M15.6 11.5h1.8v5h-1.8z'], 'M15.6 11.5h1.8v5h-1.8z'],
}, },
teamlage: {
/* Zwei Koepfe und eine steigende Linie darunter: Es geht um
Menschen UND um ihren Verlauf. Gefuellt ist nur der vordere
Kopf -- zwei gefuellte Kreise nebeneinander lesen sich als
Brille, nicht als Team. */
fuellung: ['M8.6 4.6a2.9 2.9 0 1 0 0 5.8 2.9 2.9 0 0 0 0-5.8z'],
linien: ['M8.6 4.6a2.9 2.9 0 1 0 0 5.8 2.9 2.9 0 0 0 0-5.8z',
'M15.6 5.4a2.4 2.4 0 1 0 0 4.8 2.4 2.4 0 0 0 0-4.8z',
'M4 20v-2.2', 'M9 20v-4.6', 'M14 20v-3.2', 'M19 20v-6.4'],
},
personen: { personen: {
/* Der Bart des Schlüssels bleibt Linie, der Kopf wird Fläche. */ /* Der Bart des Schlüssels bleibt Linie, der Kopf wird Fläche. */
fuellung: ['M14.4 6.2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z'], fuellung: ['M14.4 6.2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z'],
@@ -430,6 +440,34 @@
rollen: ['spicy', 'admin', 'manager', 'creator', 'scout'], ziel: 'report.html', szene: 'showbuehne' }, rollen: ['spicy', 'admin', 'manager', 'creator', 'scout'], ziel: 'report.html', szene: 'showbuehne' },
], ],
}, },
/* =============================================================
RUND UM DAS TEAM (09.09.2026)
Filipe: "so eine kategorie wie ueber die creator will ich dass
nur fuer die spicy und dogfather rolle auch ueber manager und
scouts gibt ... ich will dass die spicy und dogfather rolle
einen kompletten teil haben mit daten ueber die arbeit von den
manager und scout."
WARUM SIE HIER STEHT, direkt hinter "Rund um den Creator": Es
ist dieselbe Frage, eine Ebene hoeher. Dort geht es um die
Betreuung EINES Creators, hier um die, die betreuen.
NUR ZWEI ROLLEN. Ein Manager sieht die Gruppe nicht -- eine
Uebersicht ueber die Arbeit der Kollegen gehoert der Leitung.
Die Kachel wegzunehmen reicht dafuer nicht: Die Seite selbst
ist in workspace.js auf ['spicy','admin'] gesetzt, und die
Schnittstelle antwortet allen anderen mit 404. Eine
ausgeblendete Kachel hat noch nie etwas geschuetzt. */
{
name: 'Rund um das Team',
unter: 'Manager und Scouts – Auslastung, Lücken, Wirkung',
bereiche: [
{ name: 'Team-Lage', unter: 'Wer betreut wen, wo staut sich etwas',
zeichen: 'teamlage', ton: 22,
rollen: ['spicy', 'admin'], ziel: 'team.html', szene: 'halle' },
],
},
{ {
name: 'Team & System', name: 'Team & System',
unter: 'Was den Laden am Laufen hält', unter: 'Was den Laden am Laufen hält',
+292
View File
@@ -0,0 +1,292 @@
/* ===================================================================
Die Arbeitslage von Managern und Scouts.
Wunsch Filipe, 09.09.2026: *"so eine kategorie wie über die creator
will ich dass nur für die spicy und dogfather rolle auch über manager
und scouts gibt ... mit daten über die arbeit von den manager und
scout. keine geheimen sachen also termine, chats und geheime dateien
soll auch so bleiben dass keiner."*
DREI DINGE, DIE DIESE SEITE ANDERS MACHT ALS EINE TABELLE:
1. DIE LÜCKEN STEHEN OBEN. Ein unbetreuter Creator ist ein Zustand,
den man heute abstellen kann; eine Kennzahl ist eine Auskunft, die
man zur Kenntnis nimmt. Deshalb kommt zuerst, was fehlt.
2. AUSREISSER WERDEN MARKIERT, NICHT GEMITTELT. Wer die Zahlen
mittelt, verliert genau die Person, um die es geht. Markiert wird
gegen den MEDIAN der Gruppe und nicht gegen eine feste Zahl:
Sieben Creator sind bei einem Team von drei Leuten viel und bei
einem von zwanzig wenig.
3. JEDE ZAHL SAGT, WORAUS SIE KOMMT. Eine Zahl ohne Bezug wird
entweder geglaubt oder ignoriert -- beides ist falsch.
=================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const melde = (t) => { $('fehler').textContent = t || ''; };
async function hole(pfad, optionen = {}) {
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('nicht erlaubt'); }
return a;
}
/* Der Median statt des Mittelwerts: Ein einzelner Ausreisser zieht
einen Mittelwert mit sich und macht sich damit selbst unsichtbar.
Der Median bleibt stehen, wo die Gruppe steht. */
function median(werte) {
const w = werte.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (!w.length) return 0;
const m = Math.floor(w.length / 2);
return w.length % 2 ? w[m] : (w[m - 1] + w[m]) / 2;
}
const datum = (iso) => {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
};
const tageHer = (iso) => {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return Math.floor((Date.now() - d) / 86400000);
};
/* ---------- Eine Zahl mit Beschriftung ---------------------------- */
function kennzahl(wert, schild, art) {
const k = el('div', 'tz');
if (art) k.dataset.art = art;
k.append(el('strong', 'tz__wert', wert));
k.append(el('span', 'tz__schild', schild));
return k;
}
/* ---------- Die Lücken -------------------------------------------- */
function lueckenZeigen(l) {
const ziel = $('luecken');
ziel.textContent = '';
const bloecke = [
['creator_ohne_betreuung', 'Creator ohne Betreuung',
'Niemand ist zuständig. Das ist die Lücke, die am schnellsten weh tut.'],
['scouts_ohne_manager', 'Scouts ohne Manager',
'Sie arbeiten, aber niemand schaut mit drauf.'],
['ohne_zuteilung', 'Ohne einen einzigen Creator',
'Angelegt, aber ohne Arbeit – entweder neu oder vergessen.'],
];
let etwas = false;
for (const [schluessel, titel, satz] of bloecke) {
const liste = l[schluessel] || [];
if (!liste.length) continue;
etwas = true;
const k = el('article', 'tl');
k.append(el('h3', 'tl__titel', `${titel} · ${liste.length}`));
k.append(el('p', 'tl__satz', satz));
const namen = el('p', 'tl__namen');
/* Namen und nicht nur eine Zahl: Eine Zahl fordert eine zweite
Frage, und die stellt niemand. */
namen.textContent = liste.map((x) => x.name).join(' · ');
k.append(namen);
ziel.append(k);
}
if (!etwas) {
const k = el('article', 'tl');
k.dataset.gut = 'ja';
k.append(el('h3', 'tl__titel', 'Keine Lücken'));
k.append(el('p', 'tl__satz',
'Jeder aktive Creator hat eine Betreuung, jeder Scout einen Manager.'));
ziel.append(k);
}
ziel.hidden = false;
}
/* ---------- Die Lage in vier Zahlen -------------------------------- */
function lageZeigen(leute, luecken) {
const ziel = $('lage');
ziel.textContent = '';
const aktive = leute.filter((p) => p.aktiv);
const betreute = new Set();
for (const p of leute) for (const c of p.creator) betreute.add(c.id);
const ueberfaellig = leute.reduce((s, p) => s + p.aufgaben.ueberfaellig, 0);
const faellig = leute.reduce((s, p) => s + (p.pipeline?.faellig || 0), 0);
ziel.append(kennzahl(String(aktive.length), 'im Team'));
ziel.append(kennzahl(String(betreute.size), 'betreute Creator'));
ziel.append(kennzahl(String(ueberfaellig), 'überfällige Aufgaben',
ueberfaellig ? 'warn' : 'gut'));
ziel.append(kennzahl(String(faellig), 'liegengebliebene Kontakte',
faellig ? 'warn' : 'gut'));
const l = (luecken.creator_ohne_betreuung || []).length;
ziel.append(kennzahl(String(l), 'Creator ohne Betreuung', l ? 'warn' : 'gut'));
ziel.hidden = false;
}
/* ---------- Eine Person ------------------------------------------- */
function karte(p, vergleich) {
const k = el('article', 'tperson');
k.dataset.rolle = p.rolle;
if (!p.aktiv) k.dataset.gesperrt = 'ja';
/* Kopf: Bild, Name, was er trägt. */
const kopf = el('div', 'tperson__kopf');
if (p.bild) {
const b = document.createElement('img');
b.className = 'tperson__bild';
b.src = p.bild; b.alt = ''; b.loading = 'lazy';
kopf.append(b);
} else {
kopf.append(el('span', 'tperson__zeichen', (p.name || '?').slice(0, 1).toUpperCase()));
}
const kt = el('div', 'tperson__text');
kt.append(el('h3', 'tperson__name', p.name + (p.aktiv ? '' : ' · gesperrt')));
const unter = [];
if (p.creator.length) unter.push(`${p.creator.length} Creator direkt`);
if (p.creator_mittelbar) unter.push(`${p.creator_mittelbar} über Scouts`);
/* Einzahl und Mehrzahl. "1 Scouts" ist eine Kleinigkeit und trotzdem
das Erste, was auffaellt -- eine Seite, die ihre eigene Sprache
nicht beherrscht, wird auch bei den Zahlen nicht geglaubt. */
if (p.scouts.length) {
unter.push(`${p.scouts.length} ${p.scouts.length === 1 ? 'Scout' : 'Scouts'}: `
+ p.scouts.join(', '));
}
kt.append(el('p', 'tperson__unter', unter.join(' · ') || 'Noch niemand zugeteilt'));
kopf.append(kt);
/* Zuletzt da. Bewusst grob und ohne Uhrzeit: Es geht um
Erreichbarkeit, nicht um Arbeitszeit. */
const tage = tageHer(p.letzter_login);
const zuletzt = el('span', 'tperson__zuletzt',
tage === null ? 'noch nie angemeldet'
: tage === 0 ? 'heute da' : tage === 1 ? 'gestern da' : `vor ${tage} Tagen da`);
if (tage === null || tage > 14) zuletzt.dataset.warn = 'ja';
kopf.append(zuletzt);
k.append(kopf);
/* Die Zahlen. */
const zahlen = el('div', 'tperson__zahlen');
const last = p.creator.length + p.creator_mittelbar;
zahlen.append(kennzahl(String(last), 'Creator',
vergleich.viel && last > vergleich.viel ? 'warn' : null));
zahlen.append(kennzahl(String(p.aufgaben.laufend), 'Aufgaben laufen'));
zahlen.append(kennzahl(String(p.aufgaben.ueberfaellig), 'überfällig',
p.aufgaben.ueberfaellig ? 'warn' : 'gut'));
zahlen.append(kennzahl(String(p.aufgaben.erledigt30), 'erledigt (30 Tage)'));
zahlen.append(kennzahl(
p.aufgaben.durchlauf_tage === null ? '—' : `${p.aufgaben.durchlauf_tage} T`,
'bis erledigt'));
if (p.fortschritt.startcheck_gesamt) {
const q = Math.round(100 * p.fortschritt.startcheck_fertig / p.fortschritt.startcheck_gesamt);
zahlen.append(kennzahl(`${q} %`, 'Start-Check der Creator', q < 50 ? 'warn' : null));
}
if (p.fortschritt.punkte_gesamt) {
zahlen.append(kennzahl(String(p.fortschritt.punkte_offen), 'offene Punkte'));
}
if (p.zahlen.livetage30) {
zahlen.append(kennzahl(String(p.zahlen.livetage30), 'LIVE-Tage (30 T)'));
zahlen.append(kennzahl(p.zahlen.diamanten30.toLocaleString('de-DE'), 'Diamanten (30 T)'));
}
k.append(zahlen);
/* Die Pipeline -- nur bei Scouts, denn nur sie haben eine. */
if (p.pipeline) {
const pl = el('div', 'tpipe');
pl.append(el('p', 'tpipe__schild', 'Pipeline'));
const r = el('div', 'tpipe__reihe');
r.append(kennzahl(String(p.pipeline.offen), 'in Arbeit'));
r.append(kennzahl(String(p.pipeline.uebergeben), 'übergeben'));
r.append(kennzahl(String(p.pipeline.abgelehnt), 'abgelehnt'));
r.append(kennzahl(String(p.pipeline.faellig), 'Kontakt fällig',
p.pipeline.faellig ? 'warn' : 'gut'));
/* Die Quote sagt mehr als jede Einzelzahl: Viele Kontakte ohne
Übergabe sind Arbeit ohne Ergebnis. */
const entschieden = p.pipeline.uebergeben + p.pipeline.abgelehnt;
if (entschieden) {
const q = Math.round(100 * p.pipeline.uebergeben / entschieden);
r.append(kennzahl(`${q} %`, 'davon übernommen', q < 20 ? 'warn' : null));
}
if (p.pipeline.uebergabe_tage !== null) {
r.append(kennzahl(`${p.pipeline.uebergabe_tage} T`, 'bis zur Übergabe'));
}
pl.append(r);
k.append(pl);
}
/* Wen genau. Zugeklappt, weil es bei zwanzig Creators die Karte
sprengen würde -- aber vorhanden, weil "sieben Creator" ohne
Namen keine Auskunft ist. */
if (p.creator.length) {
const auf = el('details', 'tperson__wer');
const z = el('summary', null, `Wen ${p.name} betreut`);
auf.append(z);
const liste = el('p', 'tperson__namen',
p.creator.map((c) => c.name + (c.aktiv ? '' : ' (gesperrt)')).join(' · '));
auf.append(liste);
k.append(auf);
}
return k;
}
/* ---------- Laden -------------------------------------------------- */
async function laden() {
let daten;
try {
const a = await hole('/workspace/api/team');
if (!a.ok) { melde('Die Arbeitslage konnte nicht geladen werden.'); return; }
daten = await a.json();
} catch { return; }
$('laedt').hidden = true;
lueckenZeigen(daten.luecken || {});
lageZeigen(daten.leute || [], daten.luecken || {});
for (const [rolle, block, liste] of [
['manager', 'manager-block', 'manager-liste'],
['scout', 'scout-block', 'scout-liste'],
]) {
const eigene = (daten.leute || []).filter((p) => p.rolle === rolle);
if (!eigene.length) continue;
/* Der Vergleichswert entsteht INNERHALB der Rolle: Ein Manager
trägt naturgemäß mehr als ein Scout, und ihn daran zu messen
wäre unfair und nutzlos. */
const lasten = eigene.map((p) => p.creator.length + p.creator_mittelbar);
const mitte = median(lasten);
/* "Viel" heisst: die Hälfte über dem Median der eigenen Rolle --
und mindestens zwei mehr, damit bei kleinen Zahlen nicht schon
ein Creator Unterschied als Überlast gilt. */
const vergleich = { viel: Math.max(mitte * 1.5, mitte + 2) };
const ziel = $(liste);
ziel.textContent = '';
for (const p of eigene) ziel.append(karte(p, vergleich));
$(block).hidden = false;
}
melde('');
}
(async () => {
try {
const a = await hole('/workspace/api/ich');
if (!a.ok) { location.assign('/workspace/'); return; }
const ich = await a.json();
window.werZeigen?.(ich);
/* Die Schranke steht im Server. Hier geht es nur darum, niemanden
vor einer leeren Seite stehen zu lassen. */
if (ich.rolle !== 'admin' && ich.rolle !== 'spicy') {
location.assign('/workspace/start.html');
return;
}
} catch { return; }
await laden();
})();
})();
+9 -9
View File
@@ -14,10 +14,10 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -231,10 +231,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/aufgaben.js?v=202609091505" defer></script> <script src="assets/js/aufgaben.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/automation.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -140,10 +140,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/automation.js?v=202609091505" defer></script> <script src="assets/js/automation.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+14 -14
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/bereich.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -149,14 +149,14 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/checkliste.js?v=202609091505" defer></script> <script src="assets/js/checkliste.js?v=202609091541" defer></script>
<script src="assets/js/ampel.js?v=202609091505" defer></script> <script src="assets/js/ampel.js?v=202609091541" defer></script>
<script src="assets/js/schulung.js?v=202609091505" defer></script> <script src="assets/js/schulung.js?v=202609091541" defer></script>
<script src="assets/js/vorlagen.js?v=202609091505" defer></script> <script src="assets/js/vorlagen.js?v=202609091541" defer></script>
<script src="assets/js/bereich.js?v=202609091505" defer></script> <script src="assets/js/bereich.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/calls.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -82,11 +82,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609091505" defer></script> <script src="assets/js/ki.js?v=202609091541" defer></script>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/calls.js?v=202609091505" defer></script> <script src="assets/js/calls.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,17 +14,17 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<!-- 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=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/chat.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -247,10 +247,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/bereiche.js?v=202609091505"></script> <script src="assets/js/bereiche.js?v=202609091541"></script>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505"></script> <script src="assets/js/kopf.js?v=202609091541"></script>
<script src="assets/js/glocke.js?v=202609091505"></script> <script src="assets/js/glocke.js?v=202609091541"></script>
<script src="assets/js/chat.js?v=202609091505"></script> <script src="assets/js/chat.js?v=202609091541"></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/content.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/content.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -172,12 +172,12 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/checkliste.js?v=202609091505" defer></script> <script src="assets/js/checkliste.js?v=202609091541" defer></script>
<script src="assets/js/vorlagen.js?v=202609091505" defer></script> <script src="assets/js/vorlagen.js?v=202609091541" defer></script>
<script src="assets/js/content.js?v=202609091505" defer></script> <script src="assets/js/content.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/dateien.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -101,10 +101,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/dateien.js?v=202609091505" defer></script> <script src="assets/js/dateien.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -32,7 +32,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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
</head> </head>
<body class="gate"> <body class="gate">
@@ -401,6 +401,6 @@
</main> </main>
</div> </div>
<script src="assets/js/gate.js?v=202609091505" defer></script> <script src="assets/js/gate.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/kalender.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -370,11 +370,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/kalender.js?v=202609091505" defer></script> <script src="assets/js/kalender.js?v=202609091541" defer></script>
<script src="assets/js/kalender-abo.js?v=202609091505" defer></script> <script src="assets/js/kalender-abo.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/leistung.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -217,10 +217,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609091505"></script> <script src="assets/js/wahl.js?v=202609091541"></script>
<script src="assets/js/bereiche.js?v=202609091505"></script> <script src="assets/js/bereiche.js?v=202609091541"></script>
<script src="assets/js/kopf.js?v=202609091505"></script> <script src="assets/js/kopf.js?v=202609091541"></script>
<script src="assets/js/glocke.js?v=202609091505"></script> <script src="assets/js/glocke.js?v=202609091541"></script>
<script src="assets/js/leistung.js?v=202609091505"></script> <script src="assets/js/leistung.js?v=202609091541"></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/personen.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -335,10 +335,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/personen.js?v=202609091505" defer></script> <script src="assets/js/personen.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -207,11 +207,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/steckbrief.js?v=202609091505" defer></script> <script src="assets/js/steckbrief.js?v=202609091541" defer></script>
<script src="assets/js/profil.js?v=202609091505" defer></script> <script src="assets/js/profil.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/report.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/report.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -99,11 +99,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609091505" defer></script> <script src="assets/js/ki.js?v=202609091541" defer></script>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/report.js?v=202609091505" defer></script> <script src="assets/js/report.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/scouting.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -132,10 +132,10 @@
</div> </div>
</div> </div>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/scouting.js?v=202609091505" defer></script> <script src="assets/js/scouting.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+9 -9
View File
@@ -14,12 +14,12 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe <!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. --> den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
<link rel="stylesheet" href="assets/css/heim.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/heim.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -667,10 +667,10 @@
</p> </p>
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/start.js?v=202609091505" defer></script> <script src="assets/js/start.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/startcheck.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -78,11 +78,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609091505" defer></script> <script src="assets/js/ki.js?v=202609091541" defer></script>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/startcheck.js?v=202609091505" defer></script> <script src="assets/js/startcheck.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -119,10 +119,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/steckbrief.js?v=202609091505" defer></script> <script src="assets/js/steckbrief.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+98
View File
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Team · Spicy &amp; Dogi</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/team.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head>
<body class="start">
<div class="schleier" aria-hidden="true"></div>
<header class="kopfleiste">
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 5.5 8 12l6.5 6.5" />
</svg>
<span class="zurueck-knopf__text">Zurück</span>
</button>
<p class="marke"><span class="marke__text"><a class="zurueck" href="start.html">Creator&nbsp;Workspace</a> · Team</span></p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt inhalt--breit">
<!-- ==== DER KOPF ================================================
Er sagt in einem Satz, was diese Seite ist UND was sie nicht
ist. Der zweite Teil ist der wichtigere: Eine Seite mit Zahlen
über Kollegen wird sonst als Überwachung gelesen -- und dann
arbeitet niemand mehr offen damit.
Aus der Recherche (Fachliteratur zu Arbeitslast-Dashboards):
Der Zweck ist, Stellen zu finden, an denen Unterstützung fehlt.
Wer das nicht dazuschreibt, baut ein Kontrollwerkzeug, auch wenn
er es nicht wollte. -->
<section class="t-kopf">
<div class="t-kopf__text">
<p class="feldschild">Rund um das Team</p>
<h1 class="t-kopf__titel">Manager &amp; Scouts</h1>
<p class="t-kopf__unter" id="kopf-unter">
Wer betreut wen, wo staut sich etwas, wo fehlt jemand.
</p>
</div>
<p class="t-kopf__grenze">
Termine, Chats und Dateien kommen hier nicht vor – weder Inhalte
noch Zahlen. Das ist eine <strong>Arbeitslage</strong>, keine Überwachung.
</p>
</section>
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
<!-- ==== DIE LÜCKEN ZUERST ======================================
Eine Kennzahl sagt, wie es läuft. Eine Lücke sagt, wo etwas
fehlt -- und nur das Zweite kann man heute abstellen. Deshalb
steht sie oben und nicht unter den Tabellen. -->
<section class="t-luecken" id="luecken" hidden></section>
<!-- ==== DIE LAGE IN ZAHLEN ===================================== -->
<section class="t-lage" id="lage" hidden></section>
<!-- ==== DIE LEUTE ==============================================
Jede Person einzeln, nicht als Durchschnitt: Zwei Scouts mit je
sechs Creators und einer mit achtzehn ergeben einen Schnitt von
zehn -- und der sagt nichts über den, bei dem es klemmt. -->
<section class="t-gruppe" id="manager-block" hidden>
<h2 class="feldschild">Manager</h2>
<div class="t-liste" id="manager-liste"></div>
</section>
<section class="t-gruppe" id="scout-block" hidden>
<h2 class="feldschild">Scouts</h2>
<div class="t-liste" id="scout-liste"></div>
</section>
<p class="leise" id="laedt">Die Arbeitslage wird geladen …</p>
</main>
<script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/team.js?v=202609091541" defer></script>
</body>
</html>
+10 -10
View File
@@ -14,13 +14,13 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der <!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
Dialog "Creator anlegen". --> Dialog "Creator anlegen". -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/uebersicht.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -142,11 +142,11 @@
</main> </main>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/uebersicht.js?v=202609091505" defer></script> <script src="assets/js/uebersicht.js?v=202609091541" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -14,11 +14,11 @@
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<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=202609091505" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/start.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/start.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/wissen.css?v=202609091541" />
<link rel="stylesheet" href="assets/css/module.css?v=202609091505" /> <link rel="stylesheet" href="assets/css/module.css?v=202609091541" />
</head> </head>
<body class="start"> <body class="start">
@@ -183,10 +183,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609091505" defer></script> <script src="assets/js/wahl.js?v=202609091541" defer></script>
<script src="assets/js/bereiche.js?v=202609091505" defer></script> <script src="assets/js/bereiche.js?v=202609091541" defer></script>
<script src="assets/js/kopf.js?v=202609091505" defer></script> <script src="assets/js/kopf.js?v=202609091541" defer></script>
<script src="assets/js/glocke.js?v=202609091505" defer></script> <script src="assets/js/glocke.js?v=202609091541" defer></script>
<script src="assets/js/wissen.js?v=202609091505" defer></script> <script src="assets/js/wissen.js?v=202609091541" defer></script>
</body> </body>
</html> </html>