diff --git a/server/pruef-leistung-optik.mjs b/server/pruef-leistung-optik.mjs
new file mode 100644
index 00000000..0f1554f9
--- /dev/null
+++ b/server/pruef-leistung-optik.mjs
@@ -0,0 +1,581 @@
+/* ZAHLEN — KANN MAN SIE EINTRAGEN, UND SAGEN SIE ETWAS?
+
+ pruef-leistung.mjs beweist, dass der Server richtig rechnet. Diese
+ Prüfung fragt das, worauf es danach ankommt: Kommt jemand an die
+ Zahlen heran, und sagt ihm die Seite etwas, das er ohne sie nicht
+ wüsste?
+
+ Ein Rechenwerk ohne Oberfläche ist kein Modul, sondern ein
+ Versprechen. Genau deshalb steht diese Prüfung neben der anderen.
+
+ GEMESSEN WIRD:
+ * Ein Tag wird eingetragen und steht danach in der Wochenkarte.
+ * Der VERGLEICH zur Vorwoche erscheint -- die Zahl allein wäre eine
+ Behauptung.
+ * Die kleine Verlaufslinie wird wirklich GEZEICHNET. Nicht "das
+ Element ist da": Ihre Punkte müssen Zahlen sein. Genau hier saß
+ ein echter Fehler -- der Server liefert im Verlauf nur drei der
+ sieben Größen, für die anderen kam `undefined` an, wurde zu NaN,
+ und der Browser zeichnete wortlos nichts.
+ * Der Import zeigt ERST die Vorschau. Gegenprobe: vorher darf noch
+ nichts in der Datenbank stehen.
+ * Ein Creator SIEHT seine Zahlen und kann sie NICHT eintragen.
+ * Schriftgrößen und Fingerziele auf dem Handy.
+ * Das Dashboard zeigt die Zahlen und die Frühwarnung.
+
+ GEGENPROBEN durchgehend: Zu jeder Prüfung, die "in Ordnung" sagen
+ kann, gehört eine, die beweist, dass sie auch "nicht in Ordnung"
+ sagen könnte.
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-leistoptik-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const { portMussFreiSein } = await import("./helfer-port.mjs");
+await portMussFreiSein(4337, "die Zahlen-Optikpruefung");
+
+process.env.PORT = "4337";
+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");
+await new Promise((r) => setTimeout(r, 1000));
+const BASIS = "http://127.0.0.1:4337";
+
+const PROTOKOLL = join(process.cwd(), "pruef-leistung-optik-lauf.txt");
+try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
+const melde = (z) => {
+ console.log(z);
+ try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
+};
+let fehler = 0;
+let gemacht = 0;
+const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Bestand ------------------------------------------------------------ */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+for (let i = 0; i < 60; i++) {
+ try {
+ const p = new DatabaseSync(process.env.WORKSPACE_DB);
+ try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
+ } catch { await new Promise((r) => setTimeout(r, 250)); }
+}
+
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idMan = anlegen("Marek", "manager", "CODE-MANA-0001");
+const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idMan, jetzt);
+
+/* Ortszeit-Datum. NIE toISOString(): das ist UTC und liegt zwischen
+ Mitternacht und zwei Uhr einen Tag daneben -- eine Prüfung, die um
+ 00:30 rot wird, ohne dass sich etwas geändert hat. */
+const p2 = (n) => String(n).padStart(2, "0");
+const tagVor = (n) => {
+ const t = new Date(Date.now() - n * 86400000);
+ return `${t.getFullYear()}-${p2(t.getMonth() + 1)}-${p2(t.getDate())}`;
+};
+
+/* VORWOCHE bestücken, damit es überhaupt etwas zu vergleichen gibt --
+ und damit die Verlaufslinie genug Punkte hat. Die laufende Woche
+ bleibt leer: Sie wird gleich über die Oberfläche gefüllt, und das ist
+ der eigentliche Gegenstand dieser Prüfung. */
+const setzen = d.prepare(`
+ INSERT INTO leistung (creator_id, tag, diamanten, dauer_min, gueltiger_tag,
+ zuschauer_avg, verweildauer_s, erfasst, erfasst_von, quelle)
+ VALUES (?,?,?,?,1,?,?,?,?, 'hand')`);
+for (let i = 7; i <= 27; i++) {
+ /* Absteigend über drei Wochen -- damit auch die Frühwarnung etwas zu
+ melden hat und nicht nur "keine Befunde" heißt. */
+ setzen.run(idLuna, tagVor(i), 400 + i * 12, 90, 30, 200 + i, jetzt, idMan);
+}
+d.close();
+
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+async function alsRolle(rolle, code, wohin = "leistung.html", groesse = { width: 1280, height: 900 }) {
+ const ctx = await browser.newContext({ viewport: groesse });
+ const seite = await ctx.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click(`.rolle[data-rolle="${rolle}"]`);
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+ return { ctx, seite };
+}
+
+/* =======================================================================
+ 1. Die Seite ist überhaupt erreichbar — und über die Kachel
+ ======================================================================= */
+melde("\n=== Der Weg zur Seite ===");
+const man = await alsRolle("manager", "CODE-MANA-0001", "start.html");
+{
+ const kachel = await man.seite.$$eval(".kachel", (k) =>
+ k.map((x) => ({
+ name: x.querySelector(".kachel__name")?.textContent.trim(),
+ /* Das href sitzt am .kachel__link INNEN, nicht am
aussen --
+ beim ersten Anlauf stand hier `x.getAttribute("href")` und
+ lieferte brav "null". Eine Prüfung, die am falschen Element
+ misst, meldet einen Fehler, den es nicht gibt; das ist genauso
+ schädlich wie einer, den sie übersieht. */
+ ziel: x.querySelector(".kachel__link")?.getAttribute("href"),
+ zeichen: !!x.querySelector("svg"),
+ })).filter((x) => x.name === "Zahlen"));
+ ok(kachel.length === 1, `die Kachel "Zahlen" steht auf der Startseite (${kachel.length}×)`);
+ ok(kachel[0]?.ziel === "leistung.html", `sie führt nach "${kachel[0]?.ziel}"`);
+ ok(kachel[0]?.zeichen === true, "sie hat ein Zeichen");
+
+ /* GEGENPROBE zum Selektor: Ein Name, den es nicht gibt, darf NICHT
+ gefunden werden -- sonst prüfte der Block oben nichts. */
+ const erfunden = await man.seite.$$eval(".kachel", (k) =>
+ k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber"));
+ ok(!erfunden, "eine erfundene Kachel wird nicht gefunden");
+
+ await man.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" });
+ await man.seite.waitForTimeout(1100);
+ const titel = await man.seite.$eval(".titel", (e) => e.textContent.trim());
+ ok(titel.length > 0, `die Seite ist offen: "${titel}"`);
+}
+
+/* =======================================================================
+ 2. Einen Tag eintragen — und er steht danach in der Wochenkarte
+ ======================================================================= */
+melde("\n=== Einen Tag eintragen ===");
+{
+ const vorher = await man.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
+ ok(vorher.length >= 5,
+ `die Wochenkarten stehen da (${vorher.length} Werte) — sonst prüft alles Folgende nichts`);
+
+ /* Die laufende Woche ist leer: Diamanten müssen 0 sein. Steht hier
+ schon etwas, ist der Bestand oben falsch aufgebaut und die Prüfung
+ misst etwas anderes als sie behauptet. */
+ const diaVorher = await man.seite.$$eval(".zahlkarte", (k) => {
+ const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
+ return t?.querySelector(".zahlkarte__wert")?.textContent;
+ });
+ ok(diaVorher === "0", `vor dem Eintragen stehen ${diaVorher} Diamanten da`);
+
+ /* Der erste "eintragen"-Knopf ist der von HEUTE (die Liste läuft
+ rückwärts). */
+ const knoepfe = await man.seite.$$(".tagzeile__knopf");
+ ok(knoepfe.length === 14, `es gibt 14 Tageszeilen mit Knopf (${knoepfe.length})`);
+ await knoepfe[0].click();
+ await man.seite.waitForSelector("#tag-dialog[open]", { timeout: 5000 });
+
+ await man.seite.fill("#f-diamanten", "1250");
+ await man.seite.fill("#f-dauer", "120");
+ await man.seite.fill("#f-verweildauer", "260");
+ await man.seite.fill("#f-notiz", "PK gegen Mia");
+ await man.seite.click("#tag-speichern");
+ await man.seite.waitForTimeout(1400);
+
+ const dia = await man.seite.$$eval(".zahlkarte", (k) => {
+ const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
+ return t?.querySelector(".zahlkarte__wert")?.textContent;
+ });
+ ok(dia === "1.250", `die Karte zeigt jetzt "${dia}" (mit Tausenderpunkt)`);
+
+ /* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung -- das ist die
+ Regel, wegen der dieses Modul überhaupt so gebaut ist. */
+ const wandel = await man.seite.$$eval(".zahlkarte", (k) => {
+ const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
+ const w = t?.querySelector(".zahlkarte__wandel");
+ return { text: w?.textContent || "", richtung: w?.dataset.richtung || "" };
+ });
+ ok(/Vorwoche/.test(wandel.text) && /%/.test(wandel.text),
+ `daneben steht der Vergleich: "${wandel.text}"`);
+
+ /* DAS VORZEICHEN MUSS ZUR RICHTUNG PASSEN, und die Richtung färbt.
+ Nicht fest auf "hoch" prüfen: Die Vorwoche ist im Bestand oben
+ absichtlich stärker, es MUSS also ein Minus sein -- eine Prüfung,
+ die "hoch" erwartet, prüft dann nur noch, ob ich die Testdaten
+ richtig erinnere. Diese hier prüft den Zusammenhang selbst und
+ bleibt gültig, wenn sich der Bestand ändert. */
+ const p = Number((wandel.text.match(/(-?\d+)\s*%/) || [])[1]);
+ const soll = p > 0 ? "hoch" : p < 0 ? "runter" : "gleich";
+ ok(Number.isFinite(p) && wandel.richtung === soll,
+ `${p} % ist als "${wandel.richtung}" gekennzeichnet (erwartet "${soll}")`);
+
+ /* Und die Farbe muss der Richtung folgen -- sonst steht ein Minus in
+ Grün da und niemand merkt es. */
+ const farbe = await man.seite.$$eval(".zahlkarte", (k) => {
+ const t = k.find((x) => x.querySelector(".zahlkarte__name")?.textContent === "Diamanten");
+ return getComputedStyle(t.querySelector(".zahlkarte__wandel")).color;
+ });
+ const [r, g] = (farbe.match(/\d+/g) || []).map(Number);
+ ok(p < 0 ? r > g : g >= r,
+ `und die Farbe passt dazu (${farbe})`);
+
+ /* Die Notiz ist der Kontext, ohne den eine Zahl in die Irre führt. */
+ const notiz = await man.seite.$$eval(".tagzeile__notiz", (n) => n.map((x) => x.textContent));
+ ok(notiz.includes("PK gegen Mia"), `die Notiz steht in der Tageszeile (${notiz.length} Notizen)`);
+
+ /* GEGENPROBE: Ein Wert, der nie eingetragen wurde, darf nicht
+ dastehen. */
+ const erfunden = await man.seite.$$eval(".zahlkarte__wert", (w) =>
+ w.some((x) => x.textContent === "9.999"));
+ ok(!erfunden, "ein nie eingetragener Wert erscheint nicht");
+}
+
+/* =======================================================================
+ 3. Die Verlaufslinie wird WIRKLICH gezeichnet
+ ======================================================================= */
+melde("\n=== Die kleine Verlaufslinie ===");
+{
+ /* "Das Element ist da" reicht NICHT. Genau hier saß ein echter
+ Fehler: Der Server liefert im Verlauf nur drei der sieben Größen;
+ für alle anderen kam undefined an, wurde zu Number(undefined) = NaN
+ und der Browser zeichnete wortlos gar nichts. Ein SVG mit
+ points="NaN,NaN" ist im DOM vorhanden und auf dem Bildschirm
+ unsichtbar. Deshalb werden die PUNKTE gemessen. */
+ const linien = await man.seite.$$eval(".zahlkarte__linie polyline", (ls) =>
+ ls.map((l) => l.getAttribute("points") || ""));
+ ok(linien.length >= 3, `${linien.length} Verlaufslinien sind gezeichnet`);
+
+ const kaputt = linien.filter((p) => !p || /NaN|undefined|Infinity/.test(p));
+ ok(linien.length > 0 && kaputt.length === 0,
+ `keine Linie hat unbrauchbare Punkte (${kaputt.length} von ${linien.length})`);
+
+ /* Und sie muss WACHSEN: mindestens drei Punktepaare, sonst ist es
+ keine Linie, sondern ein Strich. */
+ const zuKurz = linien.filter((p) => p.trim().split(/\s+/).length < 3);
+ ok(linien.length > 0 && zuKurz.length === 0,
+ `jede Linie hat mindestens drei Punkte (${zuKurz.length} zu kurz)`);
+
+ /* Auch der Endpunkt-Kreis. */
+ const kreise = await man.seite.$$eval(".zahlkarte__linie circle", (cs) =>
+ cs.map((c) => `${c.getAttribute("cx")},${c.getAttribute("cy")}`));
+ ok(kreise.length === linien.length && !kreise.some((k) => /NaN/.test(k)),
+ `jede Linie hat einen Endpunkt (${kreise.length})`);
+
+ /* GEGENPROBE zum Selektor: Nach dem Entfernen darf nichts mehr
+ gefunden werden. */
+ const nachher = await man.seite.evaluate(() => {
+ document.querySelectorAll(".zahlkarte__linie").forEach((e) => e.remove());
+ return document.querySelectorAll(".zahlkarte__linie polyline").length;
+ });
+ ok(nachher === 0, "der Selektor trifft wirklich die Linien (nach dem Entfernen 0)");
+ await man.seite.reload({ waitUntil: "networkidle" });
+ await man.seite.waitForTimeout(1200);
+}
+
+/* =======================================================================
+ 4. Ziele setzen — aus einem Satz wird ein Fortschritt
+ ======================================================================= */
+melde("\n=== Ziele ===");
+{
+ const leerText = await man.seite.$eval("#zielbalken", (e) => e.textContent.trim());
+ ok(/keine Ziele/i.test(leerText), `ohne Ziel steht ein Satz da: "${leerText.slice(0, 60)}…"`);
+
+ await man.seite.click("#ziele-aendern");
+ await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
+ await man.seite.fill("#z-diamanten", "2500");
+ await man.seite.fill("#z-tage", "5");
+ await man.seite.click("#ziele-speichern");
+ await man.seite.waitForTimeout(1300);
+
+ const balken = await man.seite.$$eval(".zielbalken__zeile", (zs) =>
+ zs.map((z) => ({
+ name: z.querySelector(".zielbalken__name")?.textContent,
+ zahl: z.querySelector(".zielbalken__zahl")?.textContent,
+ breite: z.querySelector(".zielbalken__fuell")?.style.width,
+ })));
+ ok(balken.length === 2, `zwei Ziele stehen als Balken da (${balken.length})`);
+
+ const dia = balken.find((b) => b.name === "Diamanten");
+ ok(dia?.zahl === "1.250 von 2.500", `der Stand steht daneben: "${dia?.zahl}"`);
+ ok(dia?.breite === "50%", `der Balken ist zur Hälfte gefüllt (${dia?.breite})`);
+
+ /* KEIN BALKEN ÜBER 100 % -- er hinge sonst aus dem Kasten. */
+ await man.seite.click("#ziele-aendern");
+ await man.seite.waitForSelector("#ziele-dialog[open]", { timeout: 5000 });
+ await man.seite.fill("#z-diamanten", "500");
+ await man.seite.click("#ziele-speichern");
+ await man.seite.waitForTimeout(1300);
+ const voll = await man.seite.$$eval(".zielbalken__zeile", (zs) => {
+ const z = zs.find((x) => x.querySelector(".zielbalken__name")?.textContent === "Diamanten");
+ return {
+ breite: z?.querySelector(".zielbalken__fuell")?.style.width,
+ voll: z?.querySelector(".zielbalken__fuell")?.dataset.voll,
+ text: z?.querySelector(".zielbalken__anteil")?.textContent,
+ };
+ });
+ ok(voll.breite === "100%", `bei 250 % bleibt der Balken bei ${voll.breite}`);
+ ok(voll.voll === "ja", "und er ist als erreicht gekennzeichnet");
+ ok(/250/.test(voll.text || ""), `der wahre Wert steht trotzdem da: "${voll.text}"`);
+}
+
+/* =======================================================================
+ 5. Import — ERST die Vorschau, dann das Schreiben
+ ======================================================================= */
+melde("\n=== Aus Datei einlesen ===");
+{
+ const csv = [
+ "Datum;Diamanten;Dauer (Minuten);Ø Zuschauer",
+ `${tagVor(3)};1.480;95;312`,
+ `${tagVor(2)};970;80;255`,
+ "kein-datum;500;60;100",
+ ].join("\n");
+
+ await man.seite.click("#import-oeffnen");
+ await man.seite.waitForSelector("#import-dialog[open]", { timeout: 5000 });
+
+ const gesperrt = await man.seite.$eval("#import-los", (b) => b.disabled);
+ ok(gesperrt, "der Übernehmen-Knopf ist gesperrt, solange keine Datei da ist");
+
+ await man.seite.setInputFiles("#datei", {
+ name: "export.csv", mimeType: "text/csv", buffer: Buffer.from(csv, "utf8"),
+ });
+ await man.seite.waitForSelector("#vorschau:not([hidden])", { timeout: 8000 });
+
+ const zahlen = await man.seite.$$eval(".import-zahl", (zs) =>
+ zs.map((z) => `${z.querySelector(".import-zahl__wert")?.textContent} ${z.querySelector(".import-zahl__name")?.textContent}`));
+ ok(zahlen.some((z) => /^2 neu/.test(z)), `die Vorschau zählt: ${zahlen.join(" · ")}`);
+ ok(zahlen.some((z) => /^1 unlesbar/.test(z)), "die unlesbare Zeile wird gemeldet, nicht verschluckt");
+
+ const spalten = await man.seite.$eval(".import-spalten", (e) => e.textContent);
+ ok(/Diamanten/.test(spalten) && /LIVE-Dauer/.test(spalten),
+ `die erkannten Spalten stehen da: "${spalten}"`);
+
+ /* DIE GEGENPROBE, auf die es ankommt: Bis hierher darf NICHTS
+ geschrieben worden sein. Eine Vorschau, die schon schreibt, ist
+ keine Vorschau. */
+ const vorAntwort = await fetch(`${BASIS}/workspace/api/leistung/${idLuna}/tage?tage=7`, {
+ headers: { cookie: (await man.ctx.cookies()).map((c) => `${c.name}=${c.value}`).join("; ") },
+ });
+ const vorTage = (await vorAntwort.json()).tage || [];
+ const schonDa = vorTage.some((t) => t.tag === tagVor(3));
+ ok(!schonDa, "vor dem Bestätigen steht noch nichts in der Datenbank");
+
+ await man.seite.click("#import-los");
+ await man.seite.waitForTimeout(1600);
+
+ const nachTage = await man.seite.$$eval(".tagzeile", (zs) =>
+ zs.filter((z) => z.dataset.quelle === "import").length);
+ ok(nachTage === 2, `nach dem Bestätigen sind 2 Zeilen aus dem Import da (${nachTage})`);
+
+ /* DER TAUSENDERPUNKT. "1.480" darf nicht als 1,48 ankommen -- Faktor
+ tausend daneben, und die Zahl sieht trotzdem plausibel aus. */
+ const werte = await man.seite.$$eval(".tagzeile", (zs) =>
+ zs.filter((z) => z.dataset.quelle === "import")
+ .map((z) => z.querySelector(".tagzeile__wertzahl")?.textContent));
+ ok(werte.includes("1.480"), `"1.480" ist als 1480 angekommen (${werte.join(", ")})`);
+ ok(!werte.includes("1,48") && !werte.includes("1"),
+ "und nicht als 1,48 — der Tausenderpunkt wurde richtig gelesen");
+}
+
+/* =======================================================================
+ 6. Der Creator sieht seine Zahlen — und darf sie nicht eintragen
+ ======================================================================= */
+melde("\n=== Was der Creator sieht ===");
+const luna = await alsRolle("creator", "CODE-CREA-0001");
+{
+ const karten = await luna.seite.$$eval(".zahlkarte__wert", (w) => w.map((x) => x.textContent));
+ ok(karten.length >= 5, `Luna sieht ihre Zahlen (${karten.length} Werte)`);
+ /* 1250 von Hand + 1480 + 970 aus dem Import = 3700. Die Zahl steht
+ hier ausgerechnet und nicht abgeschrieben: So fällt auf, wenn der
+ Import etwas dazuzählt oder verschluckt. */
+ ok(karten.includes("3.700"),
+ `darunter ihre Diamanten, 1250+1480+970 (${karten.slice(0, 4).join(", ")})`);
+
+ const knoepfe = await luna.seite.$$(".tagzeile__knopf");
+ ok(knoepfe.length === 0, `sie hat keinen Eintragen-Knopf (${knoepfe.length})`);
+
+ const zieleKnopf = await luna.seite.$eval("#ziele-aendern", (b) => b.hidden);
+ ok(zieleKnopf, "und kann auch keine Ziele setzen");
+
+ /* Kein Auswahlfeld: Sie hat nur sich selbst, und eine Liste mit einem
+ Eintrag ist eine Frage ohne Anlass. */
+ const wahl = await luna.seite.$eval("#creator-wahl", (e) => e.hidden);
+ ok(wahl, "es gibt kein Creator-Auswahlfeld für sie");
+
+ /* DIE SCHRANKE SITZT AM SERVER, nicht in der Oberfläche. Ein
+ versteckter Knopf ist keine Sicherung. */
+ const antwort = await luna.seite.evaluate(async (tag) => {
+ const a = await fetch(`/workspace/api/leistung/3/${tag}`, {
+ method: "PUT", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ diamanten: 99999 }),
+ });
+ return a.status;
+ }, tagVor(1));
+ ok(antwort === 403, `von Hand gebaut lehnt der Server ab (HTTP ${antwort})`);
+}
+
+/* =======================================================================
+ 7. Lesbarkeit und Fingerziele
+ ======================================================================= */
+melde("\n=== Lesbar und bedienbar ===");
+{
+ /* Kleiner als 12 px liest niemand auf einem Bildschirm, an dem er
+ jeden Tag sitzt. Zweimal ist mir das schon durchgerutscht (10,56
+ und 10,88 px) -- deshalb wird es gemessen, nicht angesehen. */
+ const klein = await man.seite.$$eval(
+ ".zahlkarte *, .zielbalken *, .tagzeile *, .import-vorschau *",
+ (es) => es
+ .filter((e) => e.textContent.trim() && !e.children.length)
+ .map((e) => ({ t: e.textContent.trim().slice(0, 28), px: parseFloat(getComputedStyle(e).fontSize) }))
+ .filter((x) => x.px < 12));
+ ok(klein.length === 0,
+ `keine Schrift unter 12 px (${klein.length} gefunden: ${klein.slice(0, 3).map((k) => `${k.t} ${k.px}px`).join(" | ")})`);
+
+ /* Die ZAHL der geprüften Elemente gehört in die Bedingung, nicht nur
+ in den Meldetext. Eine leere Auswahl wäre sonst grün, ohne etwas
+ angesehen zu haben. */
+ const gezaehlt = await man.seite.$$eval(
+ ".zahlkarte *, .zielbalken *, .tagzeile *",
+ (es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
+ ok(gezaehlt > 40, `und es wurden wirklich Elemente gemessen (${gezaehlt})`);
+
+ /* HANDY: Der Eintragen-Knopf muss mit dem Daumen zu treffen sein. */
+ const handy = await alsRolle("manager", "CODE-MANA-0001", "leistung.html",
+ { width: 390, height: 844 });
+ const masse = await handy.seite.$$eval(".tagzeile__knopf", (bs) =>
+ bs.map((b) => { const r = b.getBoundingClientRect(); return { h: Math.round(r.height), w: Math.round(r.width) }; }));
+ const zuKlein = masse.filter((m) => m.h < 38 || m.w < 60);
+ ok(masse.length > 0 && zuKlein.length === 0,
+ `alle ${masse.length} Eintragen-Knöpfe sind groß genug (${zuKlein.length} zu klein)`);
+
+ /* NICHTS DARF SEITLICH HERAUSHÄNGEN. Waagerechtes Scrollen auf dem
+ Handy ist der sicherste Weg, eine Seite unbenutzbar zu machen. */
+ const breite = await handy.seite.evaluate(() => ({
+ doc: document.documentElement.scrollWidth,
+ fenster: window.innerWidth,
+ }));
+ ok(breite.doc <= breite.fenster + 1,
+ `nichts hängt seitlich heraus (${breite.doc} px auf ${breite.fenster} px)`);
+
+ /* Und die Wochenkarte bleibt einspaltig -- ein vierstelliger Wert
+ bricht sonst um, und eine umgebrochene Zahl ist keine Zahl mehr. */
+ const spalten = await handy.seite.$eval("#zahlkarten",
+ (e) => getComputedStyle(e).gridTemplateColumns.split(" ").length);
+ ok(spalten === 1, `die Wochenkarten stehen untereinander (${spalten} Spalte)`);
+
+ const wertHoehe = await handy.seite.$$eval(".zahlkarte__wert", (ws) =>
+ ws.map((w) => Math.round(w.getBoundingClientRect().height)));
+ const umbruch = wertHoehe.filter((h) => h > 42);
+ ok(wertHoehe.length > 0 && umbruch.length === 0,
+ `keine Zahl bricht um (${umbruch.length} von ${wertHoehe.length})`);
+
+ await handy.ctx.close();
+}
+
+/* =======================================================================
+ 8. Das Dashboard: Zahlen in der Karte und die Frühwarnung
+ ======================================================================= */
+melde("\n=== Dashboard ===");
+{
+ await man.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
+ await man.seite.waitForTimeout(1600);
+
+ const zahlen = await man.seite.$$eval(".kzahlen", (zs) =>
+ zs.map((z) => ({
+ ziel: z.getAttribute("href"),
+ felder: [...z.querySelectorAll(".kzahlen__feld")].map((f) =>
+ `${f.querySelector(".kzahlen__name")?.textContent}=${f.querySelector(".kzahlen__wert")?.textContent}`),
+ })));
+ ok(zahlen.length === 1, `jede Creator-Karte trägt ihre Zahlen (${zahlen.length})`);
+ ok(zahlen[0]?.felder.some((f) => /^Diamanten=3\.700$/.test(f)),
+ `die Diamanten stehen dort: ${zahlen[0]?.felder.join(" · ")}`);
+ ok(/leistung\.html\?creator=/.test(zahlen[0]?.ziel || ""),
+ `und führen zur Zahlenseite ("${zahlen[0]?.ziel}")`);
+
+ /* DIE FRÜHWARNUNG. Der Bestand oben fällt über drei Wochen ab und
+ hat diese Woche weniger LIVE-Tage -- da MUSS etwas stehen. */
+ const warnung = await man.seite.$eval("#fruehwarnung", (e) => e.hidden);
+ ok(!warnung, "die Frühwarnung ist sichtbar");
+
+ const signale = await man.seite.$$eval(".warnsignal__text", (ts) => ts.map((t) => t.textContent));
+ ok(signale.length > 0, `${signale.length} Signale: ${signale.join(" | ").slice(0, 140)}`);
+
+ /* KEINE PUNKTZAHL. Genau das war die Entscheidung: sechs benannte
+ Beobachtungen statt eines Werts, dem man nicht widersprechen kann. */
+ const punktzahl = await man.seite.evaluate(() =>
+ /risiko|score|punkte?zahl/i.test(document.getElementById("fruehwarnung")?.textContent || ""));
+ ok(!punktzahl, "und nirgends steht eine Risiko-Punktzahl");
+
+ /* Der Weg zum Gespräch. Ein Hinweis ohne Weg dahin ist nur ein
+ schlechtes Gewissen. */
+ const wege = await man.seite.$$eval(".warnkarte__weg", (ws) => ws.map((w) => w.getAttribute("href")));
+ ok(wege.length > 0 && wege.every((w) => /^chat\.html\?mit=\d+$/.test(w)),
+ `jede Warnkarte führt ins Gespräch (${wege.join(", ")})`);
+
+ /* Auch hier die Schriftgrößen -- die Dashboard-Zeile ist eine eigene
+ Fläche mit eigenem CSS. Beim ersten Lauf stand .kzahlen__name auf
+ .66 rem = 10,56 px; genau derselbe Wert, der mir schon zweimal
+ durchgerutscht ist, weil ihn niemand misst. */
+ const kleinDash = await man.seite.$$eval(".kzahlen *, .warnkarte *", (es) => es
+ .filter((e) => e.textContent.trim() && !e.children.length)
+ .map((e) => ({ t: e.textContent.trim().slice(0, 24), px: parseFloat(getComputedStyle(e).fontSize) }))
+ .filter((x) => x.px < 12));
+ const gezaehltDash = await man.seite.$$eval(".kzahlen *, .warnkarte *",
+ (es) => es.filter((e) => e.textContent.trim() && !e.children.length).length);
+ ok(gezaehltDash > 8 && kleinDash.length === 0,
+ `im Dashboard keine Schrift unter 12 px (${gezaehltDash} gemessen, ${kleinDash.length} zu klein`
+ + `${kleinDash.length ? ": " + kleinDash.slice(0, 3).map((k) => `${k.t} ${k.px}px`).join(" | ") : ""})`);
+
+ /* Und dieser Weg muss WIRKLICH funktionieren. ?mit= war bis zum
+ 06.09.2026 gar nicht vorgesehen -- ein Link, der auf einer leeren
+ Gesprächsliste endet, ist schlimmer als keiner. */
+ await man.seite.goto(`${BASIS}/workspace/${wege[0]}`, { waitUntil: "networkidle" });
+ await man.seite.waitForTimeout(1800);
+ const offen = await man.seite.$eval("#verlauf-titel", (e) => e.textContent.trim());
+ ok(offen === "Luna", `der Link öffnet das Gespräch mit "${offen}"`);
+}
+
+/* =======================================================================
+ 9. Ein Creator sieht die Frühwarnung NIE
+ ======================================================================= */
+melde("\n=== Was der Creator NICHT sieht ===");
+{
+ await luna.seite.goto(`${BASIS}/workspace/uebersicht.html`, { waitUntil: "networkidle" });
+ await luna.seite.waitForTimeout(1600);
+ const versteckt = await luna.seite.$eval("#fruehwarnung", (e) => e.hidden);
+ ok(versteckt, "die Frühwarnung bleibt bei ihr verborgen");
+
+ /* Auch von Hand abgefragt kommt nichts. Ein verstecktes Element ist
+ keine Sicherung. */
+ const roh = await luna.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/fruehwarnung", { credentials: "same-origin" });
+ return a.ok ? await a.json() : { fehler: a.status };
+ });
+ ok(Array.isArray(roh.creator) && roh.creator.length === 0 && roh.gilt_nicht === true,
+ `die Schnittstelle liefert ihr nichts (${JSON.stringify(roh).slice(0, 70)})`);
+}
+
+await luna.ctx.close();
+await man.ctx.close();
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+melde(`\n${gemacht} Pruefungen`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
+/* process.exit() ist hier richtig: Diese Prüfung startet einen
+ Express-Server, der von selbst weiterläuft (offene Verbindungen,
+ Zeitgeber für die Sicherung). Mit `exitCode` allein wartet Node
+ darauf, dass all das endet -- der Lauf liefe in die Zeitüberschreitung,
+ obwohl alle Prüfungen längst durch sind. Die Browser sind zu, bevor
+ diese Zeile läuft. */
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace.js b/server/workspace.js
index 256d3dec..d31c33ea 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -1311,6 +1311,14 @@ const GESCHUETZT = {
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
genau das Antworten ist der Zweck. */
"/workspace/chat.html": null,
+ /* Zahlen (06.09.2026): jede Rolle. WESSEN Zahlen jemand sieht,
+ entscheidet sichtbareCreatorIds() -- ein Creator sieht genau seine
+ eigenen. Ihn von der Seite auszusperren waere auch inhaltlich
+ falsch: Es sind SEINE Zahlen, und ohne sie ist jedes Ziel, das mit
+ ihm vereinbart wird, eine Behauptung. EINTRAGEN darf er sie
+ trotzdem nicht (darfEintragen() in workspace-leistung.js) -- eine
+ Zahl, die man sich selbst gibt, ist keine Messung. */
+ "/workspace/leistung.html": null,
/* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit
jeher ("Manager -- dieselben Rechte, ausser der
Personenverwaltung"), die Schranke hielt sich nur nicht daran.
diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css
index 8e9615f2..bcae75c9 100644
--- a/workspace/assets/css/chat.css
+++ b/workspace/assets/css/chat.css
@@ -178,7 +178,7 @@
}
.chat-raum__zeit {
flex: none;
- font-size: .7rem; color: var(--text-still);
+ font-size: .75rem; color: var(--text-still);
font-variant-numeric: tabular-nums;
}
.chat-raum__vorschau {
@@ -266,7 +266,7 @@
grid-column: 1;
width: 30px; height: 30px; border-radius: 50%;
display: grid; place-items: center;
- font-size: .7rem; font-weight: 700;
+ font-size: .75rem; font-weight: 700;
color: var(--r);
background: color-mix(in srgb, var(--r) 13%, transparent);
border: 1px solid color-mix(in srgb, var(--r) 28%, transparent);
@@ -326,7 +326,7 @@
margin-top: 4px;
}
.chat-nachricht__zeit {
- font-size: .68rem; color: var(--text-still);
+ font-size: .75rem; color: var(--text-still);
font-variant-numeric: tabular-nums;
}
/* Der Zurücknehmen-Knopf erscheint erst beim Darüberfahren -- er ist
@@ -334,7 +334,7 @@
Geräten ohne Zeiger steht er immer da (siehe unten). */
.chat-nachricht__weg-knopf {
padding: 0; border: 0; background: none;
- font: inherit; font-size: .68rem;
+ font: inherit; font-size: .75rem;
color: var(--text-still); cursor: pointer;
opacity: 0; transition: opacity .16s ease, color .16s ease;
}
@@ -389,7 +389,7 @@
.chat-wahl__titel {
margin: 0 0 7px;
display: flex; align-items: center; gap: 7px;
- font-size: .68rem; font-weight: 700;
+ font-size: .75rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase;
color: var(--text-still);
}
diff --git a/workspace/assets/css/leistung.css b/workspace/assets/css/leistung.css
new file mode 100644
index 00000000..767696f7
--- /dev/null
+++ b/workspace/assets/css/leistung.css
@@ -0,0 +1,284 @@
+/* ===================================================================
+ Zahlen. Baut auf gate.css, start.css und aufgaben.css auf.
+
+ DER GESTALTERISCHE GRUNDSATZ dieser Seite ist ein anderer als auf den
+ uebrigen: Hier steht nicht die Flaeche im Vordergrund, sondern die
+ ZAHL. Alles andere -- Rahmen, Beschriftung, Vergleich, Linie --
+ ordnet sich ihr unter und wird leiser, je weiter es von ihr weg ist.
+
+ Deshalb:
+ * Die Zahl in Tabellenziffern (font-variant-numeric), damit
+ untereinanderstehende Werte auf derselben Stelle sitzen. Ohne das
+ tanzen 1.240 und 987 gegeneinander, und man vergleicht schlechter.
+ * Der Vergleich in GEDECKTEM Gruen/Rot, nicht in Signalfarbe. Ein
+ Minus ist eine Auskunft, kein Alarm -- und die Seite soll
+ augenschonend bleiben (Dauerregel).
+ * Die Verlaufslinie ohne Achsen, Gitter und Beschriftung. Sie
+ beantwortet genau eine Frage ("geht es hoch oder runter?"), und
+ jede weitere Linie darin macht diese Antwort langsamer.
+ =================================================================== */
+
+/* ---------- Kopf ------------------------------------------------------- */
+
+.leistung__wahl { display: grid; gap: 6px; min-width: 220px; }
+
+/* Wie vollstaendig die Woche erfasst ist. Steht neben der Ueberschrift
+ und nicht darunter: Wer die Zahlen liest, muss diesen Satz VORHER
+ gesehen haben -- eine Woche aus zwei Tagen ist etwas anderes als eine
+ aus sieben. */
+.leistung__erfassung {
+ margin: 0; font-size: 13px; color: var(--text-still);
+}
+.leistung__erfassung[data-knapp="ja"] { color: #d9a441; }
+
+.leistung__leer {
+ margin: 0; padding: 18px; text-align: center;
+ color: var(--text-leise); font-size: 14px; line-height: 1.6;
+}
+
+.leistung-woche, .leistung-ziele, .leistung-tage { margin: 0 0 30px; }
+
+/* ---------- Die Wochenkarten -------------------------------------------- */
+
+.zahlkarten {
+ display: grid; gap: 14px;
+ grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+}
+
+.zahlkarte {
+ position: relative; overflow: hidden;
+ padding: 16px 16px 12px;
+ background: var(--flaeche);
+ border: 1px solid var(--rand);
+ border-radius: var(--radius-klein);
+ display: grid; gap: 4px;
+ /* Alle Karten gleich hoch, auch die ohne Verlaufslinie. Sonst
+ entsteht eine Treppe, und eine Treppe liest man als Bedeutung
+ ("die kurze Karte ist weniger wichtig"), obwohl sie nur ein
+ Nebeneffekt ist. */
+ align-content: start; min-height: 132px;
+}
+
+/* DIE LEITZAHL. Die Verweildauer ist seit 2026 die Groesse, an der der
+ Algorithmus alles aufhaengt -- sie darf nicht eine von sieben
+ gleichaussehenden Karten sein. Ein feiner Rand und ein Hauch Farbe
+ reichen; ein Kasten in Signalfarbe waere Laerm. */
+.zahlkarte[data-leit="ja"] {
+ border-color: color-mix(in srgb, var(--akzent) 34%, transparent);
+ background:
+ radial-gradient(120% 100% at 0% 0%,
+ color-mix(in srgb, var(--akzent) 9%, transparent), transparent 70%),
+ var(--flaeche);
+}
+
+.zahlkarte__name {
+ margin: 0; font-size: 12px; letter-spacing: .07em; text-transform: uppercase;
+ color: var(--text-still);
+}
+
+.zahlkarte__wert {
+ margin: 0; font-size: 30px; line-height: 1.1; font-weight: 650;
+ color: var(--text);
+ /* Tabellenziffern: gleiche Breite je Ziffer. Damit stehen die Werte
+ mehrerer Karten auf derselben senkrechten Linie. */
+ font-variant-numeric: tabular-nums;
+}
+
+.zahlkarte__wandel {
+ margin: 2px 0 0; font-size: 12.5px; line-height: 1.45;
+}
+/* Gedeckt, nicht grell. Gruen und Rot sind hier Auskunft, nicht Ampel --
+ und beide bleiben ueber 4,5:1 gegen die Kachelflaeche. */
+.zahlkarte__wandel[data-richtung="hoch"] { color: #6fc38a; }
+.zahlkarte__wandel[data-richtung="runter"] { color: #e08c93; }
+.zahlkarte__wandel[data-richtung="gleich"] { color: var(--text-still); }
+
+/* Die kleine Verlaufslinie. Sie sitzt unten in der Karte und laeuft
+ ueber die ganze Breite -- als Untergrund, nicht als eigenes Bild. */
+.zahlkarte__linie {
+ width: 100%; height: 30px; margin-top: 8px; display: block;
+ overflow: visible;
+}
+.zahlkarte__linie polyline {
+ fill: none; stroke: var(--akzent); stroke-width: 1.6;
+ stroke-linejoin: round; stroke-linecap: round;
+ /* Nicht mitskalieren: preserveAspectRatio="none" streckt das Bild in
+ die Breite und wuerde die Linie sonst hauchduenn ziehen. */
+ vector-effect: non-scaling-stroke;
+ opacity: .8;
+}
+.zahlkarte__linie circle {
+ fill: var(--akzent); stroke: none;
+ /* Der letzte Punkt ist das Jetzt. Er wird durch die Streckung zum
+ Ei -- deshalb bleibt er klein genug, dass es nicht auffaellt. */
+}
+.zahlkarte[data-leit="ja"] .zahlkarte__linie polyline { opacity: 1; }
+
+/* ---------- Ziele -------------------------------------------------------- */
+
+.zielbalken { display: grid; gap: 16px; }
+
+.zielbalken__zeile {
+ padding: 14px 16px;
+ background: var(--flaeche);
+ border: 1px solid var(--rand);
+ border-radius: var(--radius-klein);
+}
+.zielbalken__kopf {
+ display: flex; justify-content: space-between; align-items: baseline;
+ gap: 12px; margin-bottom: 10px; flex-wrap: wrap;
+}
+.zielbalken__name { font-size: 14px; color: var(--text); }
+.zielbalken__zahl {
+ font-size: 14px; color: var(--text-leise); font-variant-numeric: tabular-nums;
+}
+
+.zielbalken__spur {
+ height: 10px; border-radius: 999px; overflow: hidden;
+ background: rgba(150, 186, 220, 0.12);
+}
+.zielbalken__fuell {
+ height: 100%; border-radius: 999px;
+ background: linear-gradient(90deg, var(--akzent-tief), var(--akzent));
+ transition: width var(--tempo);
+}
+/* Ein erreichtes Ziel wechselt die Farbe, nicht die Groesse. Ein
+ springender Balken waere Bewegung ohne Aussage. */
+.zielbalken__fuell[data-voll="ja"] {
+ background: linear-gradient(90deg, #2f8f5b, #55c184);
+}
+.zielbalken__anteil {
+ margin: 8px 0 0; font-size: 12.5px; color: var(--text-still);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .zielbalken__fuell { transition: none; }
+}
+
+/* ---------- Die Tageszeilen ---------------------------------------------- */
+
+.tagetabelle {
+ display: grid; gap: 8px;
+}
+
+.tagzeile {
+ display: grid; align-items: center; gap: 6px 16px;
+ grid-template-columns: 74px 1fr auto;
+ padding: 12px 16px;
+ background: var(--flaeche);
+ border: 1px solid var(--rand);
+ border-radius: var(--radius-klein);
+}
+/* EIN TAG OHNE EINTRAG BLEIBT SICHTBAR -- er ist selbst die Auskunft.
+ Er wird nur leiser, damit die erfassten Tage vorn stehen. */
+.tagzeile[data-leer="ja"] {
+ background: transparent;
+ border-style: dashed;
+ border-color: rgba(150, 186, 220, 0.13);
+}
+/* Woher die Zeile kommt. Ein schmaler Streifen links, kein Etikett:
+ Man muss es wissen KOENNEN, nicht bei jedem Blick lesen. */
+.tagzeile[data-quelle="import"] {
+ border-left: 3px solid color-mix(in srgb, var(--violett) 55%, transparent);
+}
+
+.tagzeile__datum { display: grid; gap: 1px; }
+.tagzeile__wochentag {
+ font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
+ color: var(--text-still);
+}
+.tagzeile__zahl {
+ font-size: 15px; color: var(--text); font-variant-numeric: tabular-nums;
+}
+
+.tagzeile__werte {
+ display: flex; flex-wrap: wrap; gap: 6px 22px; min-width: 0;
+}
+.tagzeile__wert { display: grid; gap: 1px; min-width: 0; }
+.tagzeile__wertname {
+ font-size: 12px; letter-spacing: .05em; color: var(--text-still);
+}
+.tagzeile__wertzahl {
+ font-size: 15px; color: var(--text); font-variant-numeric: tabular-nums;
+}
+.tagzeile__leer { font-size: 13.5px; color: var(--text-still); }
+
+/* Die Notiz laeuft ueber die ganze Zeilenbreite unter den Werten --
+ sie ist der Kontext, ohne den eine eingebrochene Zahl in die Irre
+ fuehrt ("krank", "Technik ausgefallen"). */
+.tagzeile__notiz {
+ grid-column: 2 / -1;
+ margin: 2px 0 0; font-size: 13px; line-height: 1.5;
+ color: var(--text-leise);
+}
+
+.tagzeile__knopf {
+ grid-row: 1; grid-column: 3;
+ padding: 8px 14px; min-height: 38px;
+ font: inherit; font-size: 13px;
+ color: var(--text-leise); cursor: pointer;
+ background: transparent;
+ border: 1px solid var(--rand); border-radius: 999px;
+ transition: color var(--tempo), border-color var(--tempo);
+}
+.tagzeile__knopf:hover, .tagzeile__knopf:focus-visible {
+ color: var(--text); border-color: var(--rand-hell);
+}
+
+/* ---------- Der Import --------------------------------------------------- */
+
+.dialog--breit { max-width: 720px; }
+
+.import-vorschau {
+ margin: 4px 0 14px; padding: 16px;
+ background: rgba(150, 186, 220, 0.05);
+ border: 1px solid var(--rand);
+ border-radius: var(--radius-klein);
+}
+
+.import-zahlen { display: flex; flex-wrap: wrap; gap: 12px 28px; }
+.import-zahl { display: grid; gap: 2px; }
+.import-zahl__wert {
+ font-size: 26px; font-weight: 650; line-height: 1.1;
+ color: var(--text); font-variant-numeric: tabular-nums;
+}
+.import-zahl__name { font-size: 12px; color: var(--text-still); }
+.import-zahl[data-warn="ja"] .import-zahl__wert { color: #d9a441; }
+
+.import-spalten {
+ margin: 14px 0 0; font-size: 13px; line-height: 1.6;
+ color: var(--text-leise);
+}
+.import-probleme {
+ margin: 10px 0 0; padding-left: 20px;
+ font-size: 12.5px; line-height: 1.7; color: #d9a441;
+}
+
+/* ---------- Handy --------------------------------------------------------- */
+
+@media (max-width: 640px) {
+ /* Die Wochenkarten werden EINSPALTIG, nicht enger. Zwei Karten
+ nebeneinander auf 360 px lassen fuer die Zahl 150 px uebrig -- ein
+ vierstelliger Wert bricht dann um, und eine umgebrochene Zahl ist
+ keine Zahl mehr. */
+ .zahlkarten { grid-template-columns: 1fr; }
+ .zahlkarte { min-height: 0; }
+ .zahlkarte__wert { font-size: 27px; }
+
+ .leistung__wahl { min-width: 0; width: 100%; }
+
+ /* Die Tageszeile wird zweizeilig: Datum und Knopf oben, die Werte
+ darunter. Drei Spalten nebeneinander quetschen den Knopf sonst auf
+ unter 44 px -- und darunter trifft man ihn mit dem Daumen nicht
+ mehr zuverlaessig. */
+ .tagzeile {
+ grid-template-columns: 1fr auto;
+ gap: 10px 12px;
+ }
+ .tagzeile__datum { grid-row: 1; grid-column: 1; }
+ .tagzeile__knopf { grid-row: 1; grid-column: 2; }
+ .tagzeile__werte { grid-row: 2; grid-column: 1 / -1; gap: 8px 18px; }
+ .tagzeile__notiz { grid-row: 3; grid-column: 1 / -1; }
+
+ .import-zahl__wert { font-size: 23px; }
+}
diff --git a/workspace/assets/css/uebersicht.css b/workspace/assets/css/uebersicht.css
index c751550d..a55204cf 100644
--- a/workspace/assets/css/uebersicht.css
+++ b/workspace/assets/css/uebersicht.css
@@ -172,6 +172,104 @@ a.wert:hover { border-color: var(--rand-hell); background: rgba(255, 255, 255, .
}
.creator__luecke:hover { color: var(--akzent); border-color: rgba(63, 189, 245, .35); }
+/* ---------- Die Zahlen in der Karte (06.09.2026) --------------------------
+
+ Eine eigene Zeile unter den Arbeitszahlen, durch eine feine Linie
+ abgesetzt: Es sind zwei verschiedene Sorten Auskunft. Oben steht, was
+ WIR schulden (überfällig, im Review), unten, was der Kanal HERGIBT.
+ Ohne die Trennung liest man sie als eine Reihe und vergleicht Zahlen,
+ die nichts miteinander zu tun haben. */
+.kzahlen {
+ display: flex; flex-wrap: wrap; gap: 8px 26px;
+ margin: 12px 0 0; padding: 12px 0 0;
+ border-top: 1px solid var(--rand);
+ text-decoration: none; color: inherit;
+}
+.kzahlen:hover .kzahlen__wert, .kzahlen:focus-visible .kzahlen__wert { color: var(--akzent); }
+.kzahlen[data-leer="ja"] { border-top-style: dashed; }
+
+.kzahlen__feld { display: grid; gap: 1px; min-width: 0; }
+.kzahlen__name {
+ font-size: .75rem; letter-spacing: .06em; text-transform: uppercase;
+ color: var(--text-still);
+}
+.kzahlen__wert {
+ font-size: 1.05rem; color: var(--text);
+ /* Tabellenziffern: gleiche Breite je Ziffer, damit die Werte mehrerer
+ Karten untereinander auf derselben Stelle sitzen. */
+ font-variant-numeric: tabular-nums;
+ transition: color var(--tempo);
+}
+.kzahlen__wandel { margin-left: 7px; font-size: .8rem; }
+/* Gedeckt, nicht grell -- eine Auskunft, kein Alarm. */
+.kzahlen__wandel[data-richtung="hoch"] { color: #6fc38a; }
+.kzahlen__wandel[data-richtung="runter"] { color: #e08c93; }
+.kzahlen__wandel[data-richtung="gleich"] { color: var(--text-still); }
+
+.kzahlen__leer { font-size: .82rem; color: var(--text-still); }
+
+/* ---------- Die Frühwarnung ------------------------------------------------
+
+ Bernstein, nicht Rot. "Hier würde ich nachfassen" ist ein Auftrag,
+ kein Fehler -- und Rot wäre auf einer Seite, die man jeden Morgen
+ aufmacht, nach drei Tagen Tapete. Dieselbe Farbe wie das Merkzeichen
+ in den Bereichen, aus demselben Grund. */
+.warnblock { margin: 0 0 26px; }
+
+.warnliste {
+ display: grid; gap: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+}
+
+.warnkarte {
+ padding: 14px 16px 12px;
+ background:
+ radial-gradient(120% 100% at 0% 0%, rgba(217, 164, 65, .07), transparent 68%),
+ var(--flaeche);
+ border: 1px solid rgba(217, 164, 65, .28);
+ border-radius: var(--radius-klein);
+}
+.warnkarte__kopf {
+ display: flex; align-items: baseline; justify-content: space-between;
+ gap: 12px; margin-bottom: 10px;
+}
+.warnkarte__name {
+ font-size: 1rem; font-weight: 600; color: var(--text); text-decoration: none;
+}
+.warnkarte__name:hover, .warnkarte__name:focus-visible { color: var(--akzent); }
+
+/* Der Weg zum Gespräch. Klein und ruhig -- er soll da sein, wenn man
+ ihn sucht, und nicht mit dem Namen um Aufmerksamkeit ringen. */
+.warnkarte__weg {
+ flex: none;
+ padding: 5px 12px; min-height: 32px;
+ display: inline-flex; align-items: center;
+ font-size: .8rem; color: var(--text-leise); text-decoration: none;
+ border: 1px solid var(--rand); border-radius: 999px;
+ transition: color var(--tempo), border-color var(--tempo);
+}
+.warnkarte__weg:hover, .warnkarte__weg:focus-visible {
+ color: var(--akzent); border-color: rgba(63, 189, 245, .35);
+}
+
+.warnkarte__signale { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
+
+.warnsignal {
+ display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px;
+ padding-left: 14px; position: relative;
+}
+/* Ein kleiner Punkt statt eines Aufzählungszeichens -- sechs Signale
+ untereinander mit Spiegelstrichen sähen aus wie eine Mängelliste. */
+.warnsignal::before {
+ content: ''; position: absolute; left: 0; top: .55em;
+ width: 5px; height: 5px; border-radius: 50%;
+ background: rgba(217, 164, 65, .75);
+}
+.warnsignal__text { font-size: .84rem; line-height: 1.55; color: var(--text); }
+.warnsignal__beleg {
+ font-size: .8rem; color: var(--text-still); font-variant-numeric: tabular-nums;
+}
+
/* ---------- Rest ----------------------------------------------------------- */
.stand {
@@ -188,6 +286,12 @@ a.wert:hover { border-color: var(--rand-hell); background: rgba(255, 255, 255, .
.naechstes { justify-items: flex-start; text-align: left; }
.summen { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stand { text-align: left; }
+
+ /* Drei Zahlen nebeneinander lassen auf 360 px je 100 px -- "Ø
+ Verweildauer" bricht dann in drei Zeilen um. Zwei Spalten, die
+ dritte darunter. */
+ .kzahlen { gap: 10px 20px; }
+ .warnliste { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/workspace/assets/js/bereiche.js b/workspace/assets/js/bereiche.js
index 1a256e04..1ac73b83 100644
--- a/workspace/assets/js/bereiche.js
+++ b/workspace/assets/js/bereiche.js
@@ -125,6 +125,16 @@
],
automation: [
'M13.2 3.2 6 13.4h5.2l-.8 7.4 7.6-10.6h-5.2z'
+ ],
+ /* Zahlen (06.09.2026): die Flaeche UNTER einem steigenden
+ Linienzug. Bewusst kein zweites Balkendiagramm -- "Reports &
+ Review" hat schon eins, und zwei Kacheln mit demselben Zeichen
+ sind schlimmer als eine ohne: Man klickt zweimal falsch, bevor
+ man merkt, dass es zwei verschiedene Sachen sind. Eine Linie
+ ist auch die richtige Aussage: Es geht hier nicht um einen
+ Bericht, sondern um einen VERLAUF. */
+ zahlen: [
+ 'M4 16 9.5 11.5 13 14 20 6.5 20 19.5 4 19.5z'
]
};
@@ -247,6 +257,15 @@
fuellung: ['M13.2 3.2 6 13.4h5.2l-.8 7.4 7.6-10.6h-5.2z'],
linien: ['M13.2 3.2 6 13.4h5.2l-.8 7.4 7.6-10.6h-5.2z'],
},
+ zahlen: {
+ fuellung: ['M4 16 9.5 11.5 13 14 20 6.5 20 19.5 4 19.5z'],
+ /* Der Linienzug selbst, die Grundlinie darunter und ein Punkt am
+ oberen Ende -- der letzte Messwert, das Jetzt. Genau dieselbe
+ Bildsprache wie die kleine Linie in jeder Zahlkarte: Wer die
+ Kachel gesehen hat, erkennt die Karten wieder. */
+ linien: ['M4 16 9.5 11.5 13 14 20 6.5', 'M3.6 19.8h16.8',
+ 'M20 5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z'],
+ },
};
/* ---------- Die Bereiche -------------------------------------------------
@@ -315,6 +334,22 @@
Ein Creator sieht nur seine eigene Akte (dort steht auch sein
Steckbrief mit drin, er hat ja nur eine Seite). Alle anderen
bekommen beide Kacheln getrennt. */
+ /* ZAHLEN STEHT VORN (06.09.2026).
+ Der Workspace organisierte bis dahin ARBEIT hervorragend und
+ wusste über das GESCHÄFT nichts -- keine Diamanten, keine
+ LIVE-Tage, keine Verweildauer. Damit hing die ganze Gruppe
+ hier in der Luft: Der Start-Check bewertet, ohne zu messen,
+ der Report fragt "was hat funktioniert?" ohne Beleg, und das
+ 90-Tage-Ziel war ein Satz statt eines Fortschritts. Deshalb
+ die erste Kachel der Gruppe, nicht die letzte.
+
+ Ton 4 (Violett) ist von Hand gewählt und nicht gerechnet: Er
+ liegt weit von seinem Nachbarn 15 (Blaugrün) und von 8
+ (Orangerot) darunter. Wer diese Gruppe umsortiert, lässt
+ tools/kachel-farben.mjs neu laufen -- die Nachbarschaften
+ haben sich durch diese Kachel geändert. */
+ { name: 'Zahlen', unter: 'Diamanten, LIVE-Tage, Verweildauer', zeichen: 'zahlen', ton: 4,
+ rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'leistung.html', szene: 'arena' },
{ name: 'Mein Steckbrief', unter: 'Dein Bild und deine Kanäle', zeichen: 'steckbrief', ton: 15,
rollen: ['admin', 'manager', 'scout'], ziel: 'steckbrief.html', szene: 'halle' },
{ name: 'Creator-Profile', unter: 'Ziele und 90-Tage-Plan', zeichen: 'profil', ton: 8,
diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js
index 1901865b..3a386777 100644
--- a/workspace/assets/js/chat.js
+++ b/workspace/assets/js/chat.js
@@ -637,7 +637,31 @@
/* Kommt man aus einer Benachrichtigung, steht das Gespräch in der
Adresse -- dann direkt hinein. */
- const wunsch = Number(new URLSearchParams(location.search).get('raum'));
- if (Number.isInteger(wunsch) && wunsch > 0) await raumOeffnen(wunsch);
+ const suche = new URLSearchParams(location.search);
+ const wunsch = Number(suche.get('raum'));
+ if (Number.isInteger(wunsch) && wunsch > 0) { await raumOeffnen(wunsch); return; }
+
+ /* ?mit= -- der Weg von woanders her (06.09.2026).
+ Die Frühwarnung im Dashboard schlägt vor nachzufassen; ohne
+ diesen Einstieg müsste man danach die Gesprächsliste durchsuchen
+ oder ein Gespräch von Hand anlegen, das vielleicht schon
+ existiert. `/direkt` legt beim ersten Mal an und findet beim
+ zweiten dasselbe wieder -- ein Aufruf für beide Fälle.
+
+ Die Berechtigung prüft der Server (darfSchreibenMit). Eine von
+ Hand getippte Personennummer bringt hier nichts. */
+ const mit = Number(suche.get('mit'));
+ if (!Number.isInteger(mit) || mit < 1) return;
+ try {
+ const a = await hole('/workspace/api/chat/direkt', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mit }),
+ });
+ if (!a.ok) return; /* die Liste steht ja */
+ const { raum_id: raumId } = await a.json();
+ await raeumeLaden();
+ await raumOeffnen(raumId);
+ } catch { /* die Liste steht ja */ }
})();
})();
diff --git a/workspace/assets/js/leistung.js b/workspace/assets/js/leistung.js
new file mode 100644
index 00000000..0274a014
--- /dev/null
+++ b/workspace/assets/js/leistung.js
@@ -0,0 +1,590 @@
+/* =====================================================================
+ leistung.js — die Zahlen über das Geschäft.
+
+ Stufe 1 des Plans vom 31.08.2026. Der Server rechnet (siehe
+ workspace-leistung.js), diese Datei zeigt und nimmt entgegen.
+
+ DREI DINGE, die jede Zahl hier mitbringt -- sonst fliegt sie raus:
+ 1. den WERT
+ 2. den VERGLEICH zur Vorwoche ("+23 %")
+ 3. den VERLAUF als kleine Linie
+
+ Eine Zahl ohne diese drei ist eine Eitelkeitszahl. Sie sieht nach
+ Auskunft aus, und man kann nichts entscheiden.
+ ===================================================================== */
+(() => {
+ '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 LEITUNG = new Set(['admin', 'manager']);
+ let ich = null;
+ let creator = [];
+ let offenerCreator = 0;
+ let stand = null;
+ let importText = '';
+ let importSpalten = null;
+
+ async function hole(weg, einst) {
+ const a = await fetch(weg, { credentials: 'same-origin', ...einst });
+ if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); }
+ return a;
+ }
+ const melde = (t) => { $('fehler').textContent = t || ''; };
+
+ /* ---------- Zahlen lesbar machen ---------------------------------------- */
+
+ /** 1240 → "1.240". Tausenderpunkte, damit man vierstellige Zahlen
+ * auf einen Blick erfasst. */
+ const zahl = (n) => (n === null || n === undefined ? '–' : Number(n).toLocaleString('de-DE'));
+
+ /** Sekunden → "3:30 Min". Eine Verweildauer von 210 Sekunden liest
+ * niemand als dreieinhalb Minuten, wenn sie als "210" dasteht. */
+ function dauer(s) {
+ if (s === null || s === undefined) return '–';
+ const m = Math.floor(s / 60);
+ const rest = Math.round(s % 60);
+ return m ? `${m}:${String(rest).padStart(2, '0')} Min` : `${rest} Sek`;
+ }
+
+ /** Minuten → "1 Std 35". */
+ function stunden(min) {
+ if (min === null || min === undefined) return '–';
+ const h = Math.floor(min / 60);
+ const m = min % 60;
+ return h ? `${h} Std ${m}` : `${m} Min`;
+ }
+
+ /* ---------- Die Wochenkarten -------------------------------------------- */
+
+ const FELDER = [
+ /* Die Verweildauer steht VORN. Sie ist die Leitzahl (Recherche
+ 06.09.2026), nicht eine von acht. */
+ { key: 'verweildauer_s', name: 'Ø Verweildauer', form: dauer, leit: true },
+ { key: 'diamanten', name: 'Diamanten', form: zahl },
+ { key: 'gueltige_tage', name: 'LIVE-Tage', form: (n) => `${n} von 7` },
+ { key: 'dauer_min', name: 'LIVE-Zeit', form: stunden },
+ { key: 'zuschauer_avg', name: 'Ø Zuschauer', form: zahl },
+ { key: 'schenker', name: 'Schenker', form: zahl },
+ { key: 'follower_neu', name: 'Neue Follower', form: zahl },
+ ];
+
+ function wochenKartenBauen() {
+ const ziel = $('zahlkarten');
+ ziel.textContent = '';
+ if (!stand) return;
+
+ for (const f of FELDER) {
+ const w = stand.woche[f.key];
+ if (!w) continue;
+
+ const karte = el('article', 'zahlkarte');
+ if (f.leit) karte.dataset.leit = 'ja';
+
+ karte.append(el('p', 'zahlkarte__name', f.name));
+ karte.append(el('p', 'zahlkarte__wert', f.form(w.wert)));
+
+ /* DER VERGLEICH. Ohne ihn ist die Zahl eine Behauptung. */
+ const v = el('p', 'zahlkarte__wandel');
+ if (w.wandel?.prozent !== undefined) {
+ const p = w.wandel.prozent;
+ v.dataset.richtung = p > 0 ? 'hoch' : p < 0 ? 'runter' : 'gleich';
+ v.textContent = `${p > 0 ? '+' : ''}${p} % gegenüber der Vorwoche`;
+ } else if (w.wandel?.neu) {
+ /* "Von null auf 500" -- keine Prozent, sondern die Auskunft,
+ die dahintersteckt. */
+ v.dataset.richtung = 'hoch';
+ v.textContent = 'neu – vorher nichts';
+ } else {
+ v.dataset.richtung = 'gleich';
+ v.textContent = w.vorher === null ? 'kein Vergleich' : 'wie in der Vorwoche';
+ }
+ karte.append(v);
+
+ /* DER VERLAUF als kleine Linie. Acht Wochen -- ohne Richtung ist
+ ein Wert nur ein Punkt. */
+ const linie = verlaufsLinie(stand.verlauf, f.key);
+ if (linie) karte.append(linie);
+
+ ziel.append(karte);
+ }
+ }
+
+ /** Eine kleine Linie aus den letzten acht Wochen.
+ *
+ * Selbst gezeichnet als SVG-Pfad, kein Diagrammpaket: Für acht
+ * Punkte eine Bibliothek zu laden wäre in einem Haus mit drei
+ * Abhängigkeiten die vierte -- und sie brächte Farben, Achsen und
+ * Beschriftungen mit, die hier alle stören würden. */
+ function verlaufsLinie(verlauf, key) {
+ if (!Array.isArray(verlauf) || verlauf.length < 3) return null;
+ /* == null FAENGT AUCH undefined -- und genau darauf kam es an.
+ Der Server liefert im Verlauf nur drei der sieben Groessen
+ (Diamanten, LIVE-Tage, Verweildauer); fuer alle anderen ist
+ w[key] undefined. Mit `=== null` rutschte das durch, wurde zu
+ Number(undefined) = NaN, und die Linie bekam Punkte wie
+ "NaN,NaN" -- der Browser zeichnet dann gar nichts und sagt
+ nichts. Eine leere Stelle in der Karte, ohne Fehlermeldung. */
+ const werte = verlauf.map((w) => {
+ const r = w?.[key];
+ if (r === null || r === undefined) return null;
+ const n = Number(r);
+ return Number.isFinite(n) ? n : null;
+ });
+ const echte = werte.filter((w) => w !== null);
+ if (echte.length < 3) return null;
+
+ const max = Math.max(...echte);
+ const min = Math.min(...echte);
+ const spanne = max - min || 1;
+ const B = 100, H = 26;
+
+ const punkte = werte.map((w, i) => {
+ if (w === null) return null;
+ const x = (i / (werte.length - 1)) * B;
+ /* Von unten nach oben: In SVG wächst y nach unten. */
+ const y = H - ((w - min) / spanne) * (H - 4) - 2;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).filter(Boolean);
+
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('class', 'zahlkarte__linie');
+ svg.setAttribute('viewBox', `0 0 ${B} ${H}`);
+ svg.setAttribute('preserveAspectRatio', 'none');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.innerHTML = ``;
+ /* Der letzte Punkt bekommt einen Kreis -- er ist das Jetzt. */
+ const letzter = punkte[punkte.length - 1].split(',');
+ svg.innerHTML += ``;
+ return svg;
+ }
+
+ /* ---------- Ziele als Fortschritt --------------------------------------- */
+
+ function zieleBauen() {
+ const ziel = $('zielbalken');
+ ziel.textContent = '';
+ const z = stand?.ziele;
+ $('ziele-aendern').hidden = !darfEintragen();
+
+ if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) {
+ ziel.append(el('p', 'leistung__leer',
+ darfEintragen()
+ ? 'Noch keine Ziele gesetzt. Aus „mehr Reichweite" wird hier eine prüfbare Größe.'
+ : 'Noch keine Ziele gesetzt.'));
+ return;
+ }
+
+ for (const [key, name, form] of [
+ ['diamanten', 'Diamanten', zahl],
+ ['tage', 'LIVE-Tage', (n) => String(n)],
+ ['verweildauer', 'Ø Verweildauer', dauer],
+ ]) {
+ const t = z[key];
+ if (!t) continue;
+ const zeile = el('div', 'zielbalken__zeile');
+ const kopf = el('div', 'zielbalken__kopf');
+ kopf.append(el('span', 'zielbalken__name', name));
+ kopf.append(el('span', 'zielbalken__zahl', `${form(t.ist)} von ${form(t.ziel)}`));
+ zeile.append(kopf);
+
+ const spur = el('div', 'zielbalken__spur');
+ const fuell = el('div', 'zielbalken__fuell');
+ /* Über 100 % wird nicht breiter -- der Balken bliebe sonst aus
+ dem Kasten hängen. Die Zahl daneben sagt es trotzdem. */
+ fuell.style.width = `${Math.min(100, Math.max(0, t.anteil))}%`;
+ if (t.anteil >= 100) fuell.dataset.voll = 'ja';
+ spur.append(fuell);
+ zeile.append(spur);
+ zeile.append(el('p', 'zielbalken__anteil',
+ t.anteil >= 100 ? `Ziel erreicht (${t.anteil} %)` : `${t.anteil} %`));
+ ziel.append(zeile);
+ }
+ }
+
+ const darfEintragen = () => LEITUNG.has(ich?.rolle) || ich?.rolle === 'scout';
+
+ /* ---------- Die Tageszeilen --------------------------------------------- */
+
+ async function tageLaden() {
+ let tage = [];
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/tage?tage=21`);
+ if (a.ok) tage = (await a.json()).tage || [];
+ } catch { return; }
+
+ const ziel = $('tagetabelle');
+ ziel.textContent = '';
+ const nach = new Map(tage.map((t) => [t.tag, t]));
+
+ /* DIE LETZTEN 14 TAGE, auch die ohne Eintrag. Nur die vorhandenen
+ zu zeigen hieße, dass eine Lücke unsichtbar ist -- und genau die
+ Lücke ist die Auskunft: Da war nichts, oder es wurde nicht
+ erfasst. */
+ for (let i = 0; i < 14; i++) {
+ const d = new Date();
+ d.setDate(d.getDate() - i);
+ const p = (n) => String(n).padStart(2, '0');
+ const tag = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
+ const z = nach.get(tag);
+
+ const zeile = el('div', 'tagzeile');
+ if (!z) zeile.dataset.leer = 'ja';
+ if (z?.quelle === 'import') zeile.dataset.quelle = 'import';
+
+ const datum = el('div', 'tagzeile__datum');
+ datum.append(el('span', 'tagzeile__wochentag',
+ d.toLocaleDateString('de-DE', { weekday: 'short' })));
+ datum.append(el('span', 'tagzeile__zahl',
+ d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })));
+ zeile.append(datum);
+
+ const werte = el('div', 'tagzeile__werte');
+ if (z) {
+ werte.append(wert('Diamanten', zahl(z.diamanten)));
+ werte.append(wert('LIVE', stunden(z.dauer_min)));
+ werte.append(wert('Verweil', dauer(z.verweildauer_s)));
+ werte.append(wert('Ø Zusch.', zahl(z.zuschauer_avg)));
+ } else {
+ werte.append(el('span', 'tagzeile__leer', 'nichts erfasst'));
+ }
+ zeile.append(werte);
+
+ if (z?.notiz) {
+ const n = el('p', 'tagzeile__notiz', z.notiz);
+ zeile.append(n);
+ }
+
+ if (darfEintragen()) {
+ const knopf = el('button', 'tagzeile__knopf', z ? 'ändern' : 'eintragen');
+ knopf.type = 'button';
+ knopf.setAttribute('aria-label',
+ `${tag} ${z ? 'ändern' : 'eintragen'}`);
+ knopf.addEventListener('click', () => tagOeffnen(tag, z));
+ zeile.append(knopf);
+ }
+ ziel.append(zeile);
+ }
+ }
+
+ function wert(name, text) {
+ const s = el('span', 'tagzeile__wert');
+ s.append(el('span', 'tagzeile__wertname', name));
+ s.append(el('span', 'tagzeile__wertzahl', text));
+ return s;
+ }
+
+ /* ---------- Eintragen ---------------------------------------------------- */
+
+ function tagOeffnen(tag, z) {
+ const d = $('tag-dialog');
+ d.dataset.tag = tag;
+ const datum = new Date(tag + 'T12:00:00');
+ $('tag-titel').textContent = datum.toLocaleDateString('de-DE',
+ { weekday: 'long', day: 'numeric', month: 'long' });
+ $('tag-unter').textContent = z?.quelle === 'import'
+ ? 'Diese Zeile kam aus einer Datei. Änderungen von Hand bleiben erhalten, bis erneut eingelesen wird.'
+ : 'Leer lassen heißt: nicht gemessen. Eine Null ist etwas anderes.';
+
+ $('f-diamanten').value = z?.diamanten ?? '';
+ $('f-dauer').value = z?.dauer_min ?? '';
+ $('f-verweildauer').value = z?.verweildauer_s ?? '';
+ $('f-zavg').value = z?.zuschauer_avg ?? '';
+ $('f-zmax').value = z?.zuschauer_max ?? '';
+ $('f-schenker').value = z?.schenker ?? '';
+ $('f-follower').value = z?.follower_neu ?? '';
+ $('f-notiz').value = z?.notiz ?? '';
+ $('tag-fehler').textContent = '';
+ $('tag-loeschen').hidden = !z;
+ d.showModal();
+ $('f-diamanten').focus();
+ }
+
+ async function tagSpeichern(e) {
+ e.preventDefault();
+ const d = $('tag-dialog');
+ const tag = d.dataset.tag;
+ const n = (id) => {
+ const v = $(id).value.trim();
+ return v === '' ? null : Number(v);
+ };
+ const dauerMin = n('f-dauer');
+
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/${tag}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ diamanten: n('f-diamanten'),
+ dauer_min: dauerMin,
+ /* Ein gültiger Tag ist ein Tag mit LIVE-Zeit -- daraus
+ geschlossen, statt ein weiteres Kästchen zu verlangen. */
+ gueltiger_tag: dauerMin > 0,
+ verweildauer_s: n('f-verweildauer'),
+ zuschauer_avg: n('f-zavg'),
+ zuschauer_max: n('f-zmax'),
+ schenker: n('f-schenker'),
+ follower_neu: n('f-follower'),
+ notiz: $('f-notiz').value,
+ }),
+ });
+ if (!a.ok) {
+ $('tag-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.';
+ return;
+ }
+ d.close();
+ await allesLaden();
+ } catch { /* umgeleitet */ }
+ }
+
+ async function tagLoeschen() {
+ const d = $('tag-dialog');
+ if (!window.confirm('Diese Zeile wirklich löschen? Die Zahlen des Tages sind dann weg.')) return;
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/${d.dataset.tag}`,
+ { method: 'DELETE' });
+ if (!a.ok) { $('tag-fehler').textContent = 'Ging nicht.'; return; }
+ d.close();
+ await allesLaden();
+ } catch { /* umgeleitet */ }
+ }
+
+ /* ---------- Ziele ------------------------------------------------------- */
+
+ function zieleOeffnen() {
+ const z = stand?.ziele;
+ $('z-diamanten').value = z?.diamanten?.ziel ?? '';
+ $('z-tage').value = z?.tage?.ziel ?? '';
+ $('z-verweildauer').value = z?.verweildauer?.ziel ?? '';
+ $('ziele-fehler').textContent = '';
+ $('ziele-dialog').showModal();
+ }
+
+ async function zieleSpeichern(e) {
+ e.preventDefault();
+ const n = (id) => {
+ const v = $(id).value.trim();
+ return v === '' ? null : Number(v);
+ };
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/ziele`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ diamanten_woche: n('z-diamanten'),
+ tage_woche: n('z-tage'),
+ verweildauer_s: n('z-verweildauer'),
+ }),
+ });
+ if (!a.ok) { $('ziele-fehler').textContent = 'Ging nicht.'; return; }
+ $('ziele-dialog').close();
+ await allesLaden();
+ } catch { /* umgeleitet */ }
+ }
+
+ /* ---------- Import ------------------------------------------------------ */
+
+ async function dateiGewaehlt(e) {
+ const datei = e.target.files?.[0];
+ if (!datei) return;
+ $('import-fehler').textContent = '';
+ $('vorschau').hidden = true;
+ $('import-los').disabled = true;
+
+ try {
+ importText = await datei.text();
+ } catch {
+ $('import-fehler').textContent = 'Die Datei ließ sich nicht lesen.';
+ return;
+ }
+
+ /* ERST DIE VORSCHAU. Ein Import, der sofort schreibt, ist bei
+ einer falsch zugeordneten Spalte nicht mehr zurückzuholen. */
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/import/vorschau`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: importText }),
+ });
+ const v = await a.json();
+ if (!a.ok) {
+ $('import-fehler').textContent = v.fehler || 'Die Datei konnte nicht gelesen werden.';
+ return;
+ }
+ importSpalten = v.spalten;
+ vorschauZeigen(v);
+ } catch { /* umgeleitet */ }
+ }
+
+ function vorschauZeigen(v) {
+ const ziel = $('vorschau');
+ ziel.textContent = '';
+ ziel.hidden = false;
+
+ /* Die ZAHLEN stehen vorn: Wer übernimmt, will wissen "wie viele",
+ nicht "welche". */
+ const zeile = el('div', 'import-zahlen');
+ zeile.append(importZahl(v.neu, 'neu'));
+ zeile.append(importZahl(v.ersetzt, v.ersetzt === 1 ? 'wird ersetzt' : 'werden ersetzt'));
+ if (v.fehlerhaft) zeile.append(importZahl(v.fehlerhaft, 'unlesbar', true));
+ ziel.append(zeile);
+
+ /* Welche Spalten erkannt wurden -- damit man merkt, wenn etwas
+ fehlt. Eine stillschweigend nicht erkannte Spalte wäre eine
+ Zahl, die einfach nie ankommt. */
+ const erkannt = Object.keys(importSpalten || {}).filter((k) => k !== 'tag');
+ const namen = {
+ diamanten: 'Diamanten', dauer_min: 'LIVE-Dauer', zuschauer_avg: 'Ø Zuschauer',
+ zuschauer_max: 'Spitze', verweildauer_s: 'Verweildauer',
+ schenker: 'Schenker', follower_neu: 'Follower',
+ };
+ ziel.append(el('p', 'import-spalten',
+ erkannt.length
+ ? 'Erkannt: ' + erkannt.map((k) => namen[k] || k).join(' · ')
+ : 'Außer dem Datum wurde keine Spalte erkannt – die Zahlen bleiben leer.'));
+
+ if (v.probleme?.length) {
+ const liste = el('ul', 'import-probleme');
+ for (const p of v.probleme) {
+ liste.append(el('li', null, `Zeile ${p.zeile}: ${p.grund}`));
+ }
+ ziel.append(liste);
+ }
+
+ $('import-los').disabled = !(v.neu + v.ersetzt);
+ }
+
+ function importZahl(n, name, warn = false) {
+ const k = el('div', 'import-zahl');
+ if (warn) k.dataset.warn = 'ja';
+ k.append(el('span', 'import-zahl__wert', String(n)));
+ k.append(el('span', 'import-zahl__name', name));
+ return k;
+ }
+
+ async function importUebernehmen(e) {
+ e.preventDefault();
+ if (!importText) return;
+ const knopf = $('import-los');
+ knopf.disabled = true;
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}/import`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: importText, spalten: importSpalten }),
+ });
+ const r = await a.json();
+ if (!a.ok) { $('import-fehler').textContent = r.fehler || 'Ging nicht.'; return; }
+ $('import-dialog').close();
+ $('datei').value = '';
+ importText = '';
+ await allesLaden();
+ melde(`${r.geschrieben} Tage übernommen.`);
+ setTimeout(() => melde(''), 5000);
+ } catch { /* umgeleitet */ } finally { knopf.disabled = false; }
+ }
+
+ /* ---------- Laden ------------------------------------------------------- */
+
+ async function allesLaden() {
+ if (!offenerCreator) return;
+ try {
+ const a = await hole(`/workspace/api/leistung/${offenerCreator}`);
+ if (!a.ok) { melde('Die Zahlen konnten nicht geladen werden.'); return; }
+ stand = await a.json();
+ } catch { return; }
+
+ $('woche').hidden = false;
+ $('ziele-block').hidden = false;
+ $('tage-block').hidden = false;
+
+ /* WIE VOLLSTÄNDIG IST DIE WOCHE? Eine Zahl aus zwei von sieben
+ Tagen ist etwas anderes als eine aus sieben -- ohne diesen
+ Hinweis hält man eine halbe Woche für einen Einbruch. */
+ const e = stand.erfassung;
+ $('erfassung').textContent = e.tage >= 7
+ ? 'alle 7 Tage erfasst'
+ : `${e.tage} von 7 Tagen erfasst`;
+ $('erfassung').dataset.knapp = e.tage < 4 ? 'ja' : '';
+
+ wochenKartenBauen();
+ zieleBauen();
+ await tageLaden();
+ }
+
+ /* ---------- Start -------------------------------------------------------- */
+
+ $('tag-form').addEventListener('submit', tagSpeichern);
+ $('tag-zu').addEventListener('click', () => $('tag-dialog').close());
+ $('tag-loeschen').addEventListener('click', tagLoeschen);
+ $('ziele-aendern').addEventListener('click', zieleOeffnen);
+ $('ziele-form').addEventListener('submit', zieleSpeichern);
+ $('ziele-zu').addEventListener('click', () => $('ziele-dialog').close());
+ $('import-oeffnen').addEventListener('click', () => {
+ $('vorschau').hidden = true;
+ $('import-los').disabled = true;
+ $('import-fehler').textContent = '';
+ $('import-dialog').showModal();
+ });
+ $('import-zu').addEventListener('click', () => $('import-dialog').close());
+ $('datei').addEventListener('change', dateiGewaehlt);
+ $('import-form').addEventListener('submit', importUebernehmen);
+
+ (async () => {
+ try {
+ const a = await hole('/workspace/api/ich');
+ if (!a.ok) { location.assign('/workspace/'); return; }
+ ich = await a.json();
+ } catch { return; }
+ window.werZeigen?.(ich);
+
+ /* Ein Creator sieht nur sich selbst -- ohne Auswahlfeld, das nur
+ einen Eintrag hätte. */
+ if (ich.rolle === 'creator') {
+ offenerCreator = ich.id;
+ await allesLaden();
+ return;
+ }
+
+ try {
+ const { creator: liste } = await (await hole('/workspace/api/leistung')).json();
+ creator = liste || [];
+ } catch { return; }
+
+ if (!creator.length) {
+ melde('Dir ist noch kein Creator zugeordnet.');
+ return;
+ }
+
+ $('creator-wahl').hidden = false;
+ for (const c of creator) {
+ const o = document.createElement('option');
+ o.value = String(c.id);
+ /* Die wichtigste Zahl steht gleich in der Auswahl -- so sieht
+ man beim Umschalten, wo etwas los ist. */
+ o.textContent = c.tage_erfasst
+ ? `${c.name} — ${zahl(c.diamanten)} Diamanten`
+ : `${c.name} — noch keine Zahlen`;
+ $('creator').append(o);
+ }
+ /* Vom Dashboard kommend: direkt zur richtigen Person. */
+ const wunsch = Number(new URLSearchParams(location.search).get('creator'));
+ offenerCreator = creator.some((c) => c.id === wunsch) ? wunsch : creator[0].id;
+ $('creator').value = String(offenerCreator);
+ window.personenwahl?.('creator');
+
+ $('creator').addEventListener('change', async () => {
+ offenerCreator = Number($('creator').value);
+ history.replaceState(null, '', `leistung.html?creator=${offenerCreator}`);
+ await allesLaden();
+ });
+
+ await allesLaden();
+ })();
+})();
diff --git a/workspace/assets/js/uebersicht.js b/workspace/assets/js/uebersicht.js
index cf38ca79..6e2a7cb2 100644
--- a/workspace/assets/js/uebersicht.js
+++ b/workspace/assets/js/uebersicht.js
@@ -66,7 +66,65 @@
return a;
}
- function karte(c, checkGesamt, eigen) {
+ /* ---------- Die Zahlen in der Karte -------------------------------------
+
+ Bis zum 06.09.2026 zeigte diese Karte ausschliesslich ARBEIT:
+ überfällig, offen, in Arbeit, im Review. Über das GESCHÄFT stand
+ hier nichts -- und damit fehlte ausgerechnet die Zeile, wegen der
+ man ein Dashboard aufmacht. Ein Creator konnte null überfällige
+ Aufgaben haben und gleichzeitig seit drei Wochen einbrechen; die
+ Karte sah dann tadellos aus.
+
+ Drei Zahlen, nicht sieben: Diamanten (das Ergebnis), LIVE-Tage (die
+ Anwesenheit) und die Verweildauer (die Leitgrösse, an der der
+ Algorithmus alles aufhängt). Alles Weitere steht eine Seite weiter.
+ Eine Karte, die alles zeigt, zeigt nichts. */
+ const zahlDe = (n) => Number(n || 0).toLocaleString('de-DE');
+
+ function dauerKurz(s) {
+ if (s === null || s === undefined) return '–';
+ const m = Math.floor(s / 60);
+ return m ? `${m}:${String(Math.round(s % 60)).padStart(2, '0')}` : `${Math.round(s)}s`;
+ }
+
+ function zahlenZeile(z, creatorId) {
+ const a = el('a', 'kzahlen');
+ a.href = `leistung.html?creator=${creatorId}`;
+
+ /* KEINE ZAHLEN IST EINE EIGENE AUSSAGE, keine Null. Wer hier "0
+ Diamanten" schreibt, behauptet eine Messung, die es nicht gab --
+ und der Betreuer sucht danach den Einbruch statt den fehlenden
+ Export. */
+ if (!z || !z.tage_erfasst) {
+ a.dataset.leer = 'ja';
+ a.append(el('span', 'kzahlen__leer', 'Für diese Woche sind noch keine Zahlen erfasst'));
+ return a;
+ }
+
+ const feld = (name, text, extra) => {
+ const s = el('span', 'kzahlen__feld');
+ s.append(el('span', 'kzahlen__name', name));
+ s.append(el('span', 'kzahlen__wert', text));
+ if (extra) s.append(extra);
+ return s;
+ };
+
+ /* Der Vergleich zur Vorwoche steht DIREKT an der Zahl. Ohne ihn ist
+ "1.240" eine Behauptung; mit ihm ist es eine Auskunft. */
+ let wandel = null;
+ if (z.wandel_diamanten?.prozent !== undefined) {
+ const p = z.wandel_diamanten.prozent;
+ wandel = el('span', 'kzahlen__wandel', `${p > 0 ? '+' : ''}${p} %`);
+ wandel.dataset.richtung = p > 0 ? 'hoch' : p < 0 ? 'runter' : 'gleich';
+ }
+
+ a.append(feld('Diamanten', zahlDe(z.diamanten), wandel));
+ a.append(feld('LIVE-Tage', `${z.gueltige_tage} / 7`));
+ a.append(feld('Ø Verweildauer', dauerKurz(z.verweildauer_s)));
+ return a;
+ }
+
+ function karte(c, checkGesamt, eigen, zahlen) {
const k = el('article', 'creator');
if (c.ueberfaellig > 0 || c.dringend > 0) k.dataset.achtung = 'ja';
@@ -114,6 +172,8 @@
werte.append(wert(c.erledigt30, 'erledigt 30 T.', 'aufgaben.html'));
k.append(werte);
+ k.append(zahlenZeile(zahlen, c.id));
+
/* Start-Check als Balken statt als Zahl -- "4 von 16" liest man,
ein Viertel gefuellt sieht man. */
const check = el('a', 'fortschritt');
@@ -173,6 +233,57 @@
s.hidden = false;
}
+ /* ---------- Die Frühwarnung ---------------------------------------------
+
+ Sechs benannte Signale, jedes im Klartext und mit Beleg. Bewusst
+ keine Punktzahl: "Abwanderungsrisiko 73" klingt nach Wissenschaft,
+ ist aber eine Behauptung, der man nicht widersprechen kann. Sechs
+ Sätze, die man einzeln bestreiten kann, sind ehrlicher -- und
+ handlungsfähiger, denn jeder sagt schon, was zu tun wäre. */
+ async function fruehwarnungLaden() {
+ let d;
+ try {
+ const a = await hole('/workspace/api/fruehwarnung');
+ if (!a.ok) return; /* still bleiben, nicht stören */
+ d = await a.json();
+ } catch { return; }
+
+ const leute = d.creator || [];
+ if (!leute.length) return; /* nichts zu melden ist kein Block */
+
+ const ziel = $('warnliste');
+ ziel.textContent = '';
+
+ for (const c of leute) {
+ const k = el('article', 'warnkarte');
+
+ const kopf = el('div', 'warnkarte__kopf');
+ const name = el('a', 'warnkarte__name', c.name);
+ name.href = `leistung.html?creator=${c.id}`;
+ kopf.append(name);
+ /* Der Weg zum Gespräch steht gleich daneben. Bei vier der sechs
+ Signale ist "einmal schreiben" die richtige nächste Handlung --
+ und ein Hinweis ohne Weg dahin ist nur ein schlechtes
+ Gewissen. */
+ const schreiben = el('a', 'warnkarte__weg', 'Schreiben');
+ schreiben.href = `chat.html?mit=${c.id}`;
+ kopf.append(schreiben);
+ k.append(kopf);
+
+ const liste = el('ul', 'warnkarte__signale');
+ for (const s of c.signale || []) {
+ const li = el('li', 'warnsignal');
+ li.dataset.art = s.art;
+ li.append(el('span', 'warnsignal__text', s.text));
+ if (s.beleg) li.append(el('span', 'warnsignal__beleg', s.beleg));
+ liste.append(li);
+ }
+ k.append(liste);
+ ziel.append(k);
+ }
+ $('fruehwarnung').hidden = false;
+ }
+
(async () => {
let ich;
try {
@@ -229,6 +340,23 @@
summenZeigen(creator);
+ /* Die Zahlen kommen aus EINER zusätzlichen Abfrage für alle Creator,
+ nicht aus einer je Karte. Bei zwölf Creators wären das zwölf
+ Anfragen, die alle dasselbe tun -- und die Seite stünde so lange
+ halb leer.
+
+ Und sie darf nicht scheitern dürfen: Wenn das Zahlen-Modul
+ stolpert, bleibt die Übersicht wie vorher stehen, nur ohne die
+ drei Zeilen. Eine leere Seite wäre der schlechtere Ausgang. */
+ let zahlenNach = new Map();
+ try {
+ const a = await hole('/workspace/api/leistung');
+ if (a.ok) {
+ const z = await a.json();
+ zahlenNach = new Map((z.creator || []).map((c) => [c.id, c]));
+ }
+ } catch { /* die Karten stehen auch ohne */ }
+
/* Wo etwas brennt, steht oben. Sortiert wird nach dem, was Handlung
verlangt -- nicht alphabetisch. Bei Gleichstand dann doch nach
Namen, damit die Reihenfolge stabil bleibt. */
@@ -237,9 +365,16 @@
|| String(a.name).localeCompare(String(b.name), 'de'));
const box = el('div', 'creator-liste');
- for (const c of sortiert) box.append(karte(c, d.check_gesamt || 16, eigen));
+ for (const c of sortiert) {
+ box.append(karte(c, d.check_gesamt || 16, eigen, zahlenNach.get(c.id)));
+ }
ziel.append(box);
+ /* Zum Schluss, und ohne await davor: Die Karten sollen nicht auf die
+ Frühwarnung warten. Sie schiebt sich oben ein, wenn sie da ist --
+ für einen Creator kommt sie nie (der Server liefert ihm nichts). */
+ if (!eigen) fruehwarnungLaden();
+
if (d.stand) {
/* Der Server schickt UTC. zeitpunkt() darf hier NICHT genommen
werden -- das schneidet die Zeichenkette nur auf und zeigte im
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index 6af75052..7ea3ca02 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -9,9 +9,9 @@
-
-
-
+
+
+
@@ -211,10 +211,10 @@
-
-
-
-
-
+
+
+
+
+