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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 7bf9078b..076be020 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -134,10 +134,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 08b191d0..19c0bb40 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -117,14 +117,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 38c698aa..f6dba9d2 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -76,11 +76,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 54c8443f..d8e84e4c 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -8,16 +8,16 @@ - - + + - - + + @@ -153,9 +153,9 @@ - - - - + + + + diff --git a/workspace/content.html b/workspace/content.html index 88145b5d..ffaa7073 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -166,12 +166,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index f264159b..bad00078 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -95,10 +95,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 8f25e510..e2cd9eef 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -145,6 +145,6 @@

    - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 4f10c1a7..1141b9b3 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -206,10 +206,10 @@ - - - - - + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html new file mode 100644 index 00000000..d405ddd5 --- /dev/null +++ b/workspace/leistung.html @@ -0,0 +1,207 @@ + + + + + +Zahlen · Creator Workspace + + + + + + + + + + + + + + +
    + +

    Creator Workspace · Zahlen

    +
    + … + +
    +
    + +
    + +
    +
    +

    Zahlen

    +

    Was wirklich passiert ist.

    +
    + +
    + + + + + + + + + + + + +

    + Die Zahlen kommen aus dem offiziellen Export von TikTok LIVE Backstage – + entweder von Hand eingetragen oder als Datei eingelesen. Es wird nichts + automatisch abgerufen: Dafür gibt es keine offene Schnittstelle. +

    +
    + + + +
    +

    Tag eintragen

    +

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + + +
    + + + +
    +
    +
    + + + +
    +

    Ziele je Woche

    +

    + Aus „mehr Reichweite" wird eine prüfbare Größe. Leer lassen heißt: kein Ziel. +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    + + + +
    +

    Aus dem Export einlesen

    +

    + Die CSV-Datei aus TikTok LIVE Backstage. Erst siehst du, was passieren + würde – geschrieben wird erst, wenn du bestätigst. Dieselbe Datei + zweimal einzulesen richtet keinen Schaden an. +

    + +
    + + +
    + + + + + +
    + + +
    +
    +
    + + + + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index ed77caa9..5f902bce 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -123,10 +123,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index bbf825b1..ecfe4d10 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -201,11 +201,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 8d5a740c..9f6ed1e0 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -93,11 +93,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 6cabd155..9d61e80c 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index e634cf2b..07c72f13 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -9,8 +9,8 @@ - - + + @@ -160,10 +160,10 @@

    - - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index dee17753..0d08d848 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -72,11 +72,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index f570fb02..6e589e41 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -113,10 +113,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 157190f2..69e13a6c 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -9,9 +9,9 @@ - - - + + + @@ -49,6 +49,29 @@ + + + @@ -59,10 +82,10 @@ - + - - - + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 96496fbe..0915995f 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -177,10 +177,10 @@ - - - - - + + + + +