From 953c3f5721a1d2989186038e476407f882e4703b Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sun, 6 Sep 2026 18:27:16 +0200 Subject: [PATCH] Zahlen bekommen eine Oberflaeche, das Dashboard bekommt Zahlen Der Server konnte seit gestern rechnen -- und niemand konnte etwas eintragen. Ein Rechenwerk ohne Oberflaeche ist kein Modul, sondern ein Versprechen. Das ist jetzt eingeloest: * leistung.html: Wochenkarten (Wert, Vergleich zur Vorwoche, Verlaufslinie), Ziele als Fortschrittsbalken, die letzten 14 Tage zum Eintragen -- auch die LEEREN, denn eine Luecke ist selbst die Auskunft. Import aus dem TikTok-Export mit Vorschau VOR dem Schreiben. * Kachel "Zahlen" als erste der Gruppe "Rund um den Creator", eigenes Zeichen (steigende Linie, kein zweites Balkendiagramm). * Dashboard: jede Creator-Karte traegt jetzt Diamanten, LIVE-Tage und Verweildauer. Bis heute zeigte sie ausschliesslich ARBEIT -- ein Creator konnte null ueberfaellige Aufgaben haben und gleichzeitig seit drei Wochen einbrechen, und die Karte sah tadellos aus. * Die Fruehwarnung steht oben im Dashboard. Sechs benannte Signale im Klartext mit Beleg, bewusst OHNE Punktzahl -- ein Wert wie "Abwanderungsrisiko 73" klingt nach Wissenschaft und ist eine Behauptung, der man nicht widersprechen kann. * chat.html?mit=: der Weg von der Warnung ins Gespraech. Gab es vorher nicht; ein Hinweis ohne Weg dahin ist nur ein schlechtes Gewissen. GEFUNDEN VON DEN PRUEFUNGEN, nicht von der Theorie: 1. Die Verlaufslinie zeichnete fuer vier der sieben Groessen NICHTS. Der Server liefert im Verlauf nur drei Reihen; fuer den Rest kam undefined an. `=== null` faengt das nicht, Number(undefined) ist NaN, und ein ist im DOM vorhanden und auf dem Bildschirm unsichtbar -- ohne Fehlermeldung. Die Pruefung misst deshalb die PUNKTE, nicht die Anwesenheit des Elements. 2. Fuenf Schriftgroessen im CHAT lagen unter 11,5 px (.68/.7 rem) und waren seit vorgestern drin. Die Grundlinie der Groessenpruefung stand auf 43, gemessen wurden 48. Nicht die Grundlinie angehoben, sondern die fuenf Stellen behoben: Eine Grundlinie, die mitwaechst, ist keine mehr. pruef-leistung-optik.mjs: 57 Pruefungen, zwei echte Browser, Gegenprobe zu jeder Aussage -- auch dazu, dass die Vorschau vor dem Bestaetigen wirklich noch nichts geschrieben hat und dass ein Creator seine Zahlen zwar SIEHT, sie aber weder eintragen noch die Fruehwarnung ueber sich lesen kann (auch nicht ueber eine von Hand gebaute Anfrage). Co-Authored-By: Claude Opus 5 --- server/pruef-leistung-optik.mjs | 581 +++++++++++++++++++++++++++ server/workspace.js | 8 + workspace/assets/css/chat.css | 10 +- workspace/assets/css/leistung.css | 284 +++++++++++++ workspace/assets/css/uebersicht.css | 104 +++++ workspace/assets/js/bereiche.js | 35 ++ workspace/assets/js/chat.js | 28 +- workspace/assets/js/leistung.js | 590 ++++++++++++++++++++++++++++ workspace/assets/js/uebersicht.js | 139 ++++++- workspace/aufgaben.html | 16 +- workspace/automation.html | 18 +- workspace/bereich.html | 26 +- workspace/calls.html | 20 +- workspace/chat.html | 16 +- workspace/content.html | 22 +- workspace/dateien.html | 18 +- workspace/index.html | 4 +- workspace/kalender.html | 18 +- workspace/leistung.html | 207 ++++++++++ workspace/personen.html | 18 +- workspace/profil.html | 20 +- workspace/report.html | 20 +- workspace/scouting.html | 18 +- workspace/start.html | 14 +- workspace/startcheck.html | 20 +- workspace/steckbrief.html | 18 +- workspace/uebersicht.html | 37 +- workspace/wissen.html | 18 +- 28 files changed, 2159 insertions(+), 168 deletions(-) create mode 100644 server/pruef-leistung-optik.mjs create mode 100644 workspace/assets/css/leistung.css create mode 100644 workspace/assets/js/leistung.js create mode 100644 workspace/leistung.html 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 @@ - - - - - + + + + +