diff --git a/server/index.js b/server/index.js index 234fc152..bf486894 100644 --- a/server/index.js +++ b/server/index.js @@ -35,6 +35,10 @@ import { contentRouter } from "./workspace-content.js"; import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js"; import { steckbriefRouter } from "./workspace-steckbrief.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 { ampelRouter } from "./workspace-ampel.js"; import { checklisteRouter } from "./workspace-checkliste.js"; @@ -203,6 +207,7 @@ app.use(fruehwarnungRouter); deshalb der Zugang -- 32 Byte Zufall, jederzeit widerrufbar. */ app.use(icsRouter); app.use(vorlagenRouter); +app.use(teamRouter); app.use(schulungRouter); app.use(ampelRouter); app.use(checklisteRouter); @@ -297,7 +302,68 @@ app.use((req, res, 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) => { res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => { diff --git a/server/pruef-team.mjs b/server/pruef-team.mjs index 24259a0a..5c31aaed 100644 --- a/server/pruef-team.mjs +++ b/server/pruef-team.mjs @@ -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 - Fotos, Namen und so alles sehen, alles was die in ihrem Profil - eintragen." + Filipe: "so eine kategorie wie über die creator will ich dass nur für + die spicy und dogfather rolle auch über manager und scouts gibt ... + 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 - nichts. Beim .wer__bild-Fehler im August benutzte die Pruefung ein - 1x1-Pixel-PNG -- damit kann kein Stil-Fehler sichtbar werden, weil - ein Punkt nichts ueberdecken kann. Hier wird deshalb ein RICHTIGES - 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. + 1. Sie zeigt sie den Falschen. Ein Manager, der die Auslastung + seiner Kollegen sieht, hat eine Auskunft, die ihm nicht gehört. + 2. Sie zeigt etwas, das nicht hineingehört -- Termine, Chats, + Dateien. - Und zu jeder Aussage die Gegenprobe: Wer KEIN Bild hat, muss den - Buchstaben sehen. Sonst koennte die Pruefung gruen sein, weil - ueberall Bilder erscheinen -- auch da, wo keine sind. */ + Punkt 2 wird hier auf ZWEI Wegen geprüft: an der Antwort (keine + Felder mit diesen Namen) UND am Quelltext (die Datei nennt diese + 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 { join } from "node:path"; -import { deflateSync } from "node:zlib"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; const ordner = mkdtempSync(join(tmpdir(), "ws-team-")); 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. */ -process.env.PORT = "4213"; + +import { notbremse } from "./helfer-notbremse.mjs"; +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_CODE_DOGI = "DOGI-TEST-1"; 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 }); }; -import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); -/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort - zwei Auffangnetze ein, die einen Absturz nur protokollieren -- - richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese - 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?.(); +notbremse(300_000, "pruef-team"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4345"; -let fehler = 0; -const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +let fehler = 0, geprueft = 0; +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", { method: "POST", headers: { "Content-Type": "application/json" }, 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++) { - try { - const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); +const 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) { const salt = randomBytes(16).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; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); -const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001"); -const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-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"); -d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") - .run(idTili, idScout, jetzt); +const idLuna = anlegen("Luna", "creator", "CODE-LUNA-0001"); +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 - Pruefung nicht unterscheiden, ob "alles was die eintragen" wirklich - ankommt oder ob nur Namen angezeigt werden. */ -d.prepare("UPDATE personen SET ueber_mich = ?, tiktok = ? WHERE id = ?") - .run("Manager & CEO dieser Seite", "dogfather", idDogi); -d.prepare("UPDATE personen SET ueber_mich = ?, instagram = ? WHERE id = ?") - .run("Ich kuemmere mich um die Vertraege.", "mara.mgmt", idMgr); -d.prepare("UPDATE personen SET ueber_mich = ? WHERE id = ?") - .run("Ich finde neue Leute.", idScout); -/* Tili bekommt BEWUSST nichts -- die Gegenprobe zum Platzhaltersatz. */ +/* Patrick betreut Tili und Luna, Ben betreut Roy, Mara fuehrt Patrick. + Nio hat NIEMANDEN -- das ist die Luecke, die die Seite zeigen soll. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idRoy, idBen, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt); + +const auf = (titel, creator, status, frist) => + d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist, + 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(); -/* ---- Ein echtes PNG, 600x600, einfarbig. Kein 1x1-Punkt: Der kann - nichts ueberdecken und wuerde jeden Stil-Fehler verstecken. ---- */ -function pngBauen(breite, hoehe) { - const roh = Buffer.alloc((breite * 3 + 1) * hoehe); - for (let y = 0; y < hoehe; y++) { - const z = y * (breite * 3 + 1); - roh[z] = 0; - for (let x = 0; x < breite; x++) { - roh[z + 1 + x * 3] = 200; roh[z + 2 + x * 3] = 60; roh[z + 3 + x * 3] = 120; - } +const keks = async (rolle, code) => { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +}; +const kDogi = await keks("admin", "CODE-DOGI-0001"); +const kSpicy = await keks("spicy", "CODE-SPIC-0001"); +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) => { - const l = Buffer.alloc(4); l.writeUInt32BE(daten.length); - const k = Buffer.concat([Buffer.from(art), daten]); - const c = Buffer.alloc(4); - c.writeUInt32BE(crc32(k) >>> 0); - return Buffer.concat([l, k, c]); - }; - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(breite, 0); ihdr.writeUInt32BE(hoehe, 4); - ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - stueck("IHDR", ihdr), stueck("IDAT", deflateSync(roh)), stueck("IEND", Buffer.alloc(0)), - ]); -} -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; - } + /* Ohne Anmeldung ueberhaupt nichts. */ + const ohne = await fetch(BASIS + "/workspace/api/team", { redirect: "manual" }); + ok(ohne.status === 401, `ohne Anmeldung: HTTP ${ohne.status}`); + + /* Und die SEITE ebenso -- eine offene Seite mit gesperrter + Schnittstelle waere eine leere Seite und eine offene Frage. */ + for (const [name, k, offen] of [ + ["DogFather", kDogi, true], ["Spicy Media", kSpicy, true], + ["Manager", kMara, false], ["Scout", kPat, false], ["Creator", kTili, false], + ]) { + const a = await ruf("/workspace/team.html", k); + const drin = a.status === 200; + ok(drin === offen, `team.html für ${name}: HTTP ${a.status}`); } - 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( - "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); -const browser = await chromium.launch(); + Auf zwei Wegen geprüft. Der zweite ist der stärkere. + ======================================================================= */ +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) { - const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + /* DAS HINWEISSCHILD WIRD AUSGENOMMEN -- und zwar ausdrücklich. + + 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 seite = await kontext.newPage(); - seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); - seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); - await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); - await seite.click(`.rolle[data-rolle="${rolle}"]`); - await seite.fill("#code", code); - await seite.click("button[type=submit]"); - await seite.waitForURL("**/start.html", { timeout: 15000 }); - return { kontext, seite, konsole }; + const ctx = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); + const s = await ctx.newPage(); + s.on("pageerror", (f) => konsole.push("Absturz: " + f.message)); + s.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); }); + await s.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await s.click('.rolle[data-rolle="admin"]'); + await s.fill("#code", "CODE-DOGI-0001"); + await s.click("button[type=submit]"); + 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(); } -/* ================================================================ - 1. Ein echtes Bild hochladen -- und pruefen, dass es ueberall ankommt - ================================================================ */ -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(); +console.log(`\n${geprueft} Prüfungen`); +console.log(fehler ? `${fehler} FEHLER` : "ALLES IN ORDNUNG"); 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); diff --git a/server/pruef-zwischenspeicher.mjs b/server/pruef-zwischenspeicher.mjs new file mode 100644 index 00000000..dfffd69d --- /dev/null +++ b/server/pruef-zwischenspeicher.mjs @@ -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); diff --git a/server/workspace-team.js b/server/workspace-team.js new file mode 100644 index 00000000..9c761a21 --- /dev/null +++ b/server/workspace-team.js @@ -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")}`; +} diff --git a/server/workspace.js b/server/workspace.js index d616aaf7..63b11691 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1982,6 +1982,19 @@ const GESCHUETZT = { "/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator"], "/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"], "/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/startcheck.html": null, "/workspace/wissen.html": null, diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 0c140021..a4a5edb3 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -912,6 +912,23 @@ body.start > main { position: relative; z-index: 1; } Kontrast -- deshalb dieser und nicht der erstbeste. */ [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. Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der 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 wirklich nicht reicht, und dort ist die zweite Zeile besser als ein 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) { .kopfleiste { flex-wrap: wrap; row-gap: 8px; } /* Die Marke nimmt die erste Zeile ganz ein, die Bedienelemente diff --git a/workspace/assets/css/team.css b/workspace/assets/css/team.css new file mode 100644 index 00000000..c1f6708a --- /dev/null +++ b/workspace/assets/css/team.css @@ -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; } +} diff --git a/workspace/assets/js/bereiche.js b/workspace/assets/js/bereiche.js index 6fd7b9c0..1519b75f 100644 --- a/workspace/assets/js/bereiche.js +++ b/workspace/assets/js/bereiche.js @@ -241,6 +241,16 @@ 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'], }, + 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: { /* 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'], @@ -430,6 +440,34 @@ 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', unter: 'Was den Laden am Laufen hält', diff --git a/workspace/assets/js/team.js b/workspace/assets/js/team.js new file mode 100644 index 00000000..2302ef3c --- /dev/null +++ b/workspace/assets/js/team.js @@ -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(); + })(); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 997ccf0e..740e5693 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -14,10 +14,10 @@ - - - - + + + +
@@ -231,10 +231,10 @@ - - - - - + + + + +