Rund um das Team -- die Arbeitslage von Managern und Scouts
Filipe: "so eine kategorie wie ueber die creator will ich dass nur fuer
die spicy und dogfather rolle auch ueber manager und scouts gibt ...
ich will dass es so ultra krass gut ist dass die spicy und dogfather
rolle einen kompletten teil haben mit daten ueber die arbeit von den
manager und scout. keine geheimen sachen also termine, chats und
geheime dateien soll auch so bleiben dass keiner."
--- ZUERST DIE KOPFLEISTE ---
Filipe meldete, die Kopfleiste sei bei DogFather "nicht gemacht".
Nachgemessen auf ALLEN 18 Seiten, in allen fuenf Rollen, bei drei
Breiten: einzeilig, Spanne 4 px. Und die neuen Dateien liegen
nachweislich auf dem Server (a70bc4f, `abmelden__zeichen` in der
ausgelieferten kopf.js). Das Bild war vor dem Ausliefern entstanden.
Die Messung hat aber zwei echte Sachen gefunden, die vorher niemand
gesehen hatte -- beide bei 320 px auf UNTERseiten, wo links der
Zurueck-Knopf und rechts zusaetzlich die Glocke steht: 305 px
gebraucht, 294 verfuegbar. Eine Stufe kleiner (34 px je Knopf, 4 px
Abstand) macht 283 und passt; 34 px bleiben weit ueber den 24 px
Mindestmass fuer ein Beruehrziel.
Ausserdem die Auslieferung geordnet: HTML wird immer nachgefragt,
Dateien mit Versionsstempel duerfen ein Jahr liegenbleiben (vorher
bekam ALLES `no-cache`, also auch jede Stilvorlage bei jedem Aufruf).
sw.js ausgenommen -- er wird ohne Stempel geladen, ein Fehler darin
bliebe sonst ein Jahr stehen.
--- DIE NEUE SEITE ---
workspace/team.html, nur fuer `spicy` und `admin`. Aufbau:
DIE LUECKEN ZUERST. Creator ohne Betreuung, Scouts ohne Manager,
Leute ohne einen einzigen Creator -- mit NAMEN, nicht nur als Zahl.
Eine Kennzahl sagt, wie es laeuft; eine Luecke sagt, wo etwas fehlt,
und nur das Zweite kann man heute abstellen.
DANN DIE LAGE in fuenf Zahlen, dann JEDE PERSON EINZELN: betreute
Creator, laufende und ueberfaellige Aufgaben, in 30 Tagen erledigte,
Durchlaufzeit, Startcheck-Fortschritt der betreuten Creator,
LIVE-Tage und Diamanten. Bei Scouts zusaetzlich die Pipeline mit
Uebernahmequote und Zeit bis zur Uebergabe.
EIN MANAGER TRAEGT DIE CREATOR SEINER SCOUTS MIT. Ohne das saehe
einer mit fuenf Scouts aus wie jemand ohne Arbeit.
--- DREI ENTSCHEIDUNGEN, DIE ALLES TRAGEN ---
1. TERMINE, CHATS UND DATEIEN KOMMEN NICHT VOR -- weder Inhalte noch
Zaehlungen. Ausdruecklicher Wunsch, und der richtige: Ein Kalender
verraet, wann jemand nicht da war; ein Chatzaehler, mit wem jemand
oft spricht.
Das ist keine Zusicherung im Kommentar. pruef-team liest den
Quelltext von workspace-team.js und schlaegt an, wenn eine dieser
Tabellen darin auftaucht -- mit Gegenprobe, dass die Suche `aufgaben`
und `leads` auch wirklich findet. Der Weg ueber die Antwort allein
waere schwaecher: Ein leerer Testbestand kann ein Feld verstecken.
2. SEGMENTIEREN, NICHT MITTELN. Aus der Recherche zu
Arbeitslast-Dashboards: Ein Durchschnitt versteckt genau die Person,
bei der es klemmt. Markiert wird gegen den MEDIAN der eigenen Rolle
-- ein Manager traegt naturgemaess mehr als ein Scout, und ihn daran
zu messen waere unfair und nutzlos.
3. ES IST EINE ARBEITSLAGE, KEINE UEBERWACHUNG. Das steht so auf der
Seite, im Kopf, in einem eigenen Kasten. Wer das nicht dazuschreibt,
baut ein Kontrollwerkzeug, auch wenn er es nicht wollte. Deshalb
zeigen die Kennzahlen auf ZUSTAENDE (unbetreute Creator,
liegengebliebene Kontakte) und nicht auf Anwesenheit oder Fleiss.
--- WAS DIE MESSUNG UNTERWEGS GEFUNDEN HAT ---
* Die Lead-Status hiessen anders, als ich angenommen hatte: "kontakt"
gibt es nicht. Die CHECK-Bedingung der Datenbank hat es sofort
abgelehnt -- ohne sie waere "offen" still zu klein gewesen.
* Die Pruefung fand ihr eigenes Hinweisschild: Die Antwort traegt ein
Feld `ausgenommen: ["Termine","Chats","Dateien"]`, aus dem die Seite
den Satz baut. Es wird jetzt herausgenommen UND eigens geprueft --
ignorieren waere bequem gewesen und haette kuenftig jedes Feld unter
diesem Namen durchgelassen.
* Die Lektion vom Vorlagenbrett gleich mitgenommen: alle Karten haben
einen DECKENDEN Grund. Eine Karte mit sieben Prozent Farbe auf
durchsichtigem Grund laesst das Buehnenfoto durch -- dort waren es
3,61:1. Gemessen jetzt: 6,61 bis 14,80:1. Eine der Regeln hatte den
deckenden Grund selbst wieder aufgehoben (zwei Regeln, die spaetere
gewinnt) -- gefunden, bevor es jemand sehen musste.
* "1 Scouts" statt "1 Scout". Eine Kleinigkeit, und das Erste, was
auffaellt: Eine Seite, die ihre eigene Sprache nicht beherrscht, wird
auch bei den Zahlen nicht geglaubt.
--- Pruefung ---
server/pruef-team.mjs, neu, 51 Pruefungen, alle gruen. Darunter: alle
fuenf Rollen an Schnittstelle UND Seite (Manager, Scout und Creator
bekommen 404 bzw. eine Umleitung), Spicy sieht VanVan nicht (mit
Gegenprobe an der Personenliste), die Zahlen an einem gebauten
Bestand, acht Kontrastmessungen an der wirklichen Flaeche, Handy.
server/pruef-zwischenspeicher.mjs, neu, 15 Pruefungen: was liegenbleiben
darf und was nicht -- an echten Kopfzeilen gemessen, nicht am
Quelltext. Sie meldete zuerst drei Fehler, und das war sie selbst: Sie
fragte unangemeldet und bekam Umleitungen. Eine Pruefung braucht ihre
Voraussetzung, bevor sie misst.
Ausserdem gruen: pruef-handy (das Handy fand die 320-px-Sache),
pruef-workspace-seiten, pruef-alle-wege, pruef-sicht, pruef-css-klassen.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+67
-1
@@ -35,6 +35,10 @@ import { contentRouter } from "./workspace-content.js";
|
||||
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
|
||||
import { steckbriefRouter } from "./workspace-steckbrief.js";
|
||||
import { vorlagenRouter } from "./workspace-vorlagen.js";
|
||||
/* Die Arbeitslage von Managern und Scouts -- nur fuer Spicy Media und
|
||||
DogFather. Sie ruehrt Termine, Chats und Dateien nicht an; die
|
||||
Begruendung steht im Kopf der Datei. */
|
||||
import { teamRouter } from "./workspace-team.js";
|
||||
import { schulungRouter } from "./workspace-schulung.js";
|
||||
import { ampelRouter } from "./workspace-ampel.js";
|
||||
import { checklisteRouter } from "./workspace-checkliste.js";
|
||||
@@ -203,6 +207,7 @@ app.use(fruehwarnungRouter);
|
||||
deshalb der Zugang -- 32 Byte Zufall, jederzeit widerrufbar. */
|
||||
app.use(icsRouter);
|
||||
app.use(vorlagenRouter);
|
||||
app.use(teamRouter);
|
||||
app.use(schulungRouter);
|
||||
app.use(ampelRouter);
|
||||
app.use(checklisteRouter);
|
||||
@@ -297,7 +302,68 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(SITE_DIR, { index: "index.html" }));
|
||||
/* =====================================================================
|
||||
HTML WIRD IMMER NACHGEFRAGT, ALLES ANDERE DARF LIEGENBLEIBEN
|
||||
(09.09.2026)
|
||||
|
||||
DER FALL, DER ES GEZEIGT HAT: Filipe meldete am Nachmittag, die
|
||||
Kopfleiste auf dem Handy sei "nicht gemacht" -- gemessen war sie auf
|
||||
allen 18 Seiten, in allen fuenf Rollen und bei drei Breiten
|
||||
einzeilig, und die neuen Dateien lagen nachweislich auf dem Server.
|
||||
Er sah trotzdem den alten Stand.
|
||||
|
||||
Die Ursache ist eine Luecke, die man nur bemerkt, wenn man sie sucht:
|
||||
Die HTML-Seiten gingen OHNE JEDE Cache-Angabe hinaus. Ohne
|
||||
`Cache-Control` und ohne `Expires` darf ein Browser heuristisch
|
||||
zwischenspeichern -- ueblicherweise ein Zehntel des Alters seit der
|
||||
letzten Aenderung. Bei einer Datei, die vor zwei Wochen zuletzt
|
||||
angefasst wurde, sind das anderthalb Tage.
|
||||
|
||||
Und das ist schlimmer als es klingt: In der HTML stehen die
|
||||
VERSIONSSTEMPEL der Stilvorlagen (`start.css?v=...`). Eine alte HTML
|
||||
verweist auf alte Stempel -- der Browser holt sich dann brav die
|
||||
ALTEN CSS- und JS-Dateien, obwohl die neuen bereitliegen. Der ganze
|
||||
Stempelmechanismus haengt an einer Datei, die selbst
|
||||
zwischengespeichert werden durfte.
|
||||
|
||||
Deshalb: HTML immer nachfragen (`no-cache` heisst NICHT "nicht
|
||||
speichern", sondern "vor jeder Benutzung rueckfragen" -- die Antwort
|
||||
ist meistens ein 304 ohne Rumpf und kostet fast nichts). Alles mit
|
||||
Stempel darf dagegen ein Jahr liegenbleiben; sein Name aendert sich
|
||||
ja, sobald sich der Inhalt aendert.
|
||||
|
||||
Bilder und Schriften ohne Stempel bekommen einen Tag -- lang genug,
|
||||
um jeden Seitenaufruf zu tragen, kurz genug, um nach einem Austausch
|
||||
nicht ewig alt zu bleiben. */
|
||||
app.use(express.static(SITE_DIR, {
|
||||
index: "index.html",
|
||||
setHeaders(res, pfad) {
|
||||
if (pfad.endsWith(".html")) {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
return;
|
||||
}
|
||||
/* Mit Versionsstempel in der Adresse: Der Name ist der Beweis.
|
||||
`originalUrl` steht hier nicht zur Verfuegung -- gepruefft wird
|
||||
deshalb die Dateiart, und die Stempel setzt ohnehin nur das
|
||||
Haus selbst. */
|
||||
/* AUSNAHME: sw.js. Der Service Worker wird OHNE Stempel geladen
|
||||
(glocke.js: register('/workspace/sw.js')). Ein Jahr unveraenderlich
|
||||
hiesse: Ein Fehler darin bliebe ein Jahr stehen. Browser
|
||||
begrenzen das zwar selbst auf 24 Stunden -- sich darauf zu
|
||||
verlassen waere aber genau die Sorte Annahme, die still bricht. */
|
||||
if (pfad.endsWith("sw.js")) {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
return;
|
||||
}
|
||||
if (/\.(css|js|mjs)$/.test(pfad)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
return;
|
||||
}
|
||||
if (/\.(webp|png|jpe?g|svg|gif|ico|woff2?)$/.test(pfad)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => {
|
||||
|
||||
+401
-290
@@ -1,72 +1,72 @@
|
||||
/* Prueft zwei Wuensche vom 01.09.2026:
|
||||
/* DIE ARBEITSLAGE VON MANAGERN UND SCOUTS (09.09.2026)
|
||||
|
||||
SCREEN 2: "ich will, dass wir Manager, Scouts, DogFather auch die
|
||||
Fotos, Namen und so alles sehen, alles was die in ihrem Profil
|
||||
eintragen."
|
||||
Filipe: "so eine kategorie wie über die creator will ich dass nur für
|
||||
die spicy und dogfather rolle auch über manager und scouts gibt ...
|
||||
keine geheimen sachen also termine, chats und geheime dateien soll
|
||||
auch so bleiben dass keiner."
|
||||
|
||||
SCREEN 5: "wieso seh ich mein Bild da nicht, obwohl ich schon ein Foto
|
||||
drin hab?"
|
||||
---------------------------------------------------------------------
|
||||
DER WICHTIGSTE ABSCHNITT IST DER ERSTE, NICHT DER LETZTE
|
||||
|
||||
WAS AN DIESER PRUEFUNG WICHTIG IST.
|
||||
Diese Seite zeigt Zahlen über Kollegen. Zwei Dinge daran können
|
||||
schiefgehen, und beide wären schwer wieder gutzumachen:
|
||||
|
||||
Ein Profilbild zu pruefen, ohne ein echtes Bild zu haben, beweist
|
||||
nichts. Beim .wer__bild-Fehler im August benutzte die Pruefung ein
|
||||
1x1-Pixel-PNG -- damit kann kein Stil-Fehler sichtbar werden, weil
|
||||
ein Punkt nichts ueberdecken kann. Hier wird deshalb ein RICHTIGES
|
||||
Bild hochgeladen (600x600) und danach gemessen, ob es auch die
|
||||
Flaeche fuellt, die es fuellen soll -- und ob es rund beschnitten ist
|
||||
statt quadratisch ueber den Rahmen zu ragen.
|
||||
1. Sie zeigt sie den Falschen. Ein Manager, der die Auslastung
|
||||
seiner Kollegen sieht, hat eine Auskunft, die ihm nicht gehört.
|
||||
2. Sie zeigt etwas, das nicht hineingehört -- Termine, Chats,
|
||||
Dateien.
|
||||
|
||||
Und zu jeder Aussage die Gegenprobe: Wer KEIN Bild hat, muss den
|
||||
Buchstaben sehen. Sonst koennte die Pruefung gruen sein, weil
|
||||
ueberall Bilder erscheinen -- auch da, wo keine sind. */
|
||||
Punkt 2 wird hier auf ZWEI Wegen geprüft: an der Antwort (keine
|
||||
Felder mit diesen Namen) UND am Quelltext (die Datei nennt diese
|
||||
Tabellen nicht). Der zweite Weg ist der stärkere: Er hält auch dann,
|
||||
wenn jemand morgen ein Feld hinzufügt, das im Testbestand zufällig
|
||||
leer bleibt.
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
|
||||
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-team-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
/* DATEN_ORDNER wird aus dem Datenbankpfad abgeleitet (workspace.js) --
|
||||
die Profilbilder landen also von selbst im Testordner. */
|
||||
process.env.PORT = "4213";
|
||||
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4345, "die Team-Pruefung");
|
||||
|
||||
process.env.PORT = "4345";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
await import("./index.js");
|
||||
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
|
||||
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
|
||||
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
|
||||
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
|
||||
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
|
||||
notbremse(480_000, "pruef-team");
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
const BASIS = "http://127.0.0.1:4213";
|
||||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.();
|
||||
notbremse(300_000, "pruef-team");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4345";
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
let fehler = 0, geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes, createHash } = await import("node:crypto");
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const jetzt = new Date().toISOString();
|
||||
const tagT = (v) => { const x = new Date(Date.now() + v * 86400000);
|
||||
return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}`
|
||||
+ `-${String(x.getDate()).padStart(2, "0")}`; };
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
@@ -75,267 +75,378 @@ function anlegen(name, rolle, code) {
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
|
||||
const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001");
|
||||
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
|
||||
const idVan = anlegen("VanVan", "admin", "CODE-VANV-0001"); /* der zweite Admin */
|
||||
const idMara = anlegen("Mara", "manager", "CODE-MANA-0001");
|
||||
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
||||
const idBen = anlegen("Ben", "scout", "CODE-SCOB-0001");
|
||||
const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
|
||||
.run(idTili, idScout, jetzt);
|
||||
const idLuna = anlegen("Luna", "creator", "CODE-LUNA-0001");
|
||||
const idRoy = anlegen("Roy", "creator", "CODE-ROYY-0001");
|
||||
const idFrei = anlegen("Nio", "creator", "CODE-NIOO-0001"); /* ohne Betreuung */
|
||||
|
||||
/* Jeder traegt etwas in seinen Steckbrief ein -- sonst koennte die
|
||||
Pruefung nicht unterscheiden, ob "alles was die eintragen" wirklich
|
||||
ankommt oder ob nur Namen angezeigt werden. */
|
||||
d.prepare("UPDATE personen SET ueber_mich = ?, tiktok = ? WHERE id = ?")
|
||||
.run("Manager & CEO dieser Seite", "dogfather", idDogi);
|
||||
d.prepare("UPDATE personen SET ueber_mich = ?, instagram = ? WHERE id = ?")
|
||||
.run("Ich kuemmere mich um die Vertraege.", "mara.mgmt", idMgr);
|
||||
d.prepare("UPDATE personen SET ueber_mich = ? WHERE id = ?")
|
||||
.run("Ich finde neue Leute.", idScout);
|
||||
/* Tili bekommt BEWUSST nichts -- die Gegenprobe zum Platzhaltersatz. */
|
||||
/* Patrick betreut Tili und Luna, Ben betreut Roy, Mara fuehrt Patrick.
|
||||
Nio hat NIEMANDEN -- das ist die Luecke, die die Seite zeigen soll. */
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idPat, jetzt);
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idRoy, idBen, jetzt);
|
||||
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idMara, jetzt);
|
||||
|
||||
const auf = (titel, creator, status, frist) =>
|
||||
d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, frist,
|
||||
creator_id, verantwortlich_id, erstellt, erstellt_von, erledigt_am)
|
||||
VALUES (?,?,?,'mittel',?,?,?,?,?,?)`)
|
||||
.run(titel, "", status, frist, creator, creator, jetzt, idDogi,
|
||||
status === "erledigt" ? jetzt : null);
|
||||
auf("T1", idTili, "offen", tagT(-3)); /* ueberfaellig */
|
||||
auf("T2", idTili, "arbeit", tagT(5));
|
||||
auf("T3", idLuna, "erledigt", tagT(-1));
|
||||
auf("T4", idRoy, "offen", tagT(2));
|
||||
|
||||
d.prepare(`INSERT INTO leads (name, plattform, handle, status, scout_id, erstellt, erstellt_von,
|
||||
naechster_followup, uebergeben_am) VALUES (?,?,?,?,?,?,?,?,?)`)
|
||||
.run("Kandidat A", "tiktok", "@a", "angesprochen", idPat, jetzt, idPat, tagT(-2), null);
|
||||
d.prepare(`INSERT INTO leads (name, plattform, handle, status, scout_id, erstellt, erstellt_von,
|
||||
naechster_followup, uebergeben_am) VALUES (?,?,?,?,?,?,?,?,?)`)
|
||||
.run("Kandidat B", "tiktok", "@b", "uebergeben", idPat, jetzt, idPat, null, jetzt);
|
||||
d.close();
|
||||
|
||||
/* ---- Ein echtes PNG, 600x600, einfarbig. Kein 1x1-Punkt: Der kann
|
||||
nichts ueberdecken und wuerde jeden Stil-Fehler verstecken. ---- */
|
||||
function pngBauen(breite, hoehe) {
|
||||
const roh = Buffer.alloc((breite * 3 + 1) * hoehe);
|
||||
for (let y = 0; y < hoehe; y++) {
|
||||
const z = y * (breite * 3 + 1);
|
||||
roh[z] = 0;
|
||||
for (let x = 0; x < breite; x++) {
|
||||
roh[z + 1 + x * 3] = 200; roh[z + 2 + x * 3] = 60; roh[z + 3 + x * 3] = 120;
|
||||
}
|
||||
const keks = async (rolle, code) => {
|
||||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle, code }) });
|
||||
return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
|
||||
};
|
||||
const kDogi = await keks("admin", "CODE-DOGI-0001");
|
||||
const kSpicy = await keks("spicy", "CODE-SPIC-0001");
|
||||
const kMara = await keks("manager", "CODE-MANA-0001");
|
||||
const kPat = await keks("scout", "CODE-SCOU-0001");
|
||||
const kTili = await keks("creator", "CODE-ANNA-0001");
|
||||
const ruf = (pfad, k) => fetch(BASIS + pfad, { headers: { cookie: k }, redirect: "manual" });
|
||||
|
||||
/* =======================================================================
|
||||
1. WER DARF HINEIN -- und wer nicht
|
||||
======================================================================= */
|
||||
console.log("\n=== Wer darf hinein");
|
||||
{
|
||||
for (const [name, k, soll] of [
|
||||
["DogFather", kDogi, 200], ["Spicy Media", kSpicy, 200],
|
||||
["Manager", kMara, 404], ["Scout", kPat, 404], ["Creator", kTili, 404],
|
||||
]) {
|
||||
const a = await ruf("/workspace/api/team", k);
|
||||
ok(a.status === soll, `${name}: HTTP ${a.status} (erwartet ${soll})`);
|
||||
}
|
||||
const stueck = (art, daten) => {
|
||||
const l = Buffer.alloc(4); l.writeUInt32BE(daten.length);
|
||||
const k = Buffer.concat([Buffer.from(art), daten]);
|
||||
const c = Buffer.alloc(4);
|
||||
c.writeUInt32BE(crc32(k) >>> 0);
|
||||
return Buffer.concat([l, k, c]);
|
||||
};
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(breite, 0); ihdr.writeUInt32BE(hoehe, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
stueck("IHDR", ihdr), stueck("IDAT", deflateSync(roh)), stueck("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
let tabelle = null;
|
||||
function crc32(buf) {
|
||||
if (!tabelle) {
|
||||
tabelle = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
tabelle[n] = c;
|
||||
}
|
||||
/* Ohne Anmeldung ueberhaupt nichts. */
|
||||
const ohne = await fetch(BASIS + "/workspace/api/team", { redirect: "manual" });
|
||||
ok(ohne.status === 401, `ohne Anmeldung: HTTP ${ohne.status}`);
|
||||
|
||||
/* Und die SEITE ebenso -- eine offene Seite mit gesperrter
|
||||
Schnittstelle waere eine leere Seite und eine offene Frage. */
|
||||
for (const [name, k, offen] of [
|
||||
["DogFather", kDogi, true], ["Spicy Media", kSpicy, true],
|
||||
["Manager", kMara, false], ["Scout", kPat, false], ["Creator", kTili, false],
|
||||
]) {
|
||||
const a = await ruf("/workspace/team.html", k);
|
||||
const drin = a.status === 200;
|
||||
ok(drin === offen, `team.html für ${name}: HTTP ${a.status}`);
|
||||
}
|
||||
let c = -1;
|
||||
for (const b of buf) c = tabelle[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return c ^ -1;
|
||||
}
|
||||
const BILD = pngBauen(600, 600);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* =======================================================================
|
||||
2. WAS NICHT HINEINGEHÖRT -- Termine, Chats, Dateien
|
||||
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
Auf zwei Wegen geprüft. Der zweite ist der stärkere.
|
||||
======================================================================= */
|
||||
console.log("\n=== Termine, Chats und Dateien bleiben draußen");
|
||||
{
|
||||
const daten = await (await ruf("/workspace/api/team", kDogi)).json();
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
|
||||
/* DAS HINWEISSCHILD WIRD AUSGENOMMEN -- und zwar ausdrücklich.
|
||||
|
||||
Die Antwort trägt ein Feld `ausgenommen: ["Termine","Chats",
|
||||
"Dateien"]`; daraus schreibt die Seite den Satz, der dem Betrachter
|
||||
sagt, was hier NICHT steht. Beim ersten Lauf hat die Wortsuche
|
||||
genau dieses Schild gefunden und Alarm geschlagen.
|
||||
|
||||
Es einfach zu ignorieren wäre die bequeme Lösung und eine schlechte:
|
||||
Dann könnte künftig jedes beliebige Feld unter diesem Namen
|
||||
durchrutschen. Stattdessen wird es HERAUSGENOMMEN und danach
|
||||
eigens geprüft -- was es enthalten darf, steht dann fest. */
|
||||
const { ausgenommen, ...ohneSchild } = daten;
|
||||
const text = JSON.stringify(ohneSchild).toLowerCase();
|
||||
const verboten = ["termin", "chat", "datei", "nachricht", "anhang"];
|
||||
const gefunden = verboten.filter((w) => text.includes(w));
|
||||
ok(gefunden.length === 0,
|
||||
gefunden.length ? `in der Antwort steht: ${gefunden.join(", ")}`
|
||||
: `kein einziges dieser Wörter in der Antwort (${verboten.join(", ")})`);
|
||||
ok(Array.isArray(ausgenommen) && ausgenommen.join(",") === "Termine,Chats,Dateien",
|
||||
`und das Schild nennt genau die drei (${(ausgenommen || []).join(", ")})`);
|
||||
|
||||
/* GEGENPROBE ZUR WORTSUCHE: Sie muss ein verbotenes Wort auch finden.
|
||||
Ohne sie hiesse "nichts gefunden" nur, dass nicht gesucht wurde. */
|
||||
ok(verboten.some((w) => JSON.stringify({ x: "Termine am Montag" }).toLowerCase().includes(w)),
|
||||
"Gegenprobe: die Wortsuche schlägt bei einem verbotenen Wort an");
|
||||
|
||||
/* DER STÄRKERE WEG: der Quelltext selbst. Ein leerer Testbestand
|
||||
kann ein Feld verstecken; eine Abfrage, die es gar nicht gibt,
|
||||
kann nichts liefern. */
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const quelle = readFileSync(join(HIER, "workspace-team.js"), "utf8");
|
||||
/* Nur der CODE, nicht die Kommentare -- dort steht ausdrücklich, was
|
||||
ausgenommen ist, und das soll dort auch stehen bleiben. */
|
||||
const nurCode = quelle.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
||||
const tabellen = ["termine", "termin_teilnehmer", "chat_nachrichten", "chat_raeume",
|
||||
"chat_teilnehmer", "dateien", "datei_personen"];
|
||||
const benutzt = tabellen.filter((t) => new RegExp(`\\b${t}\\b`).test(nurCode));
|
||||
ok(benutzt.length === 0,
|
||||
benutzt.length ? `der Quelltext fragt ab: ${benutzt.join(", ")}`
|
||||
: `keine dieser ${tabellen.length} Tabellen wird abgefragt`);
|
||||
|
||||
/* GEGENPROBE: Die Suche findet eine Tabelle, die WIRKLICH benutzt
|
||||
wird. Ohne sie hiesse "nichts gefunden" nur, dass die Suche nicht
|
||||
sucht. */
|
||||
ok(/\baufgaben\b/.test(nurCode) && /\bleads\b/.test(nurCode),
|
||||
"Gegenprobe: `aufgaben` und `leads` werden gefunden — die Suche sucht wirklich");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
3. STIMMEN DIE ZAHLEN?
|
||||
======================================================================= */
|
||||
console.log("\n=== Die Zahlen");
|
||||
{
|
||||
const daten = await (await ruf("/workspace/api/team", kDogi)).json();
|
||||
const p = (name) => daten.leute.find((x) => x.name === name);
|
||||
|
||||
ok(daten.leute.length === 3,
|
||||
`drei Leute: Manager und zwei Scouts (${daten.leute.map((x) => x.name).join(", ")})`);
|
||||
ok(!daten.leute.some((x) => x.rolle === "creator" || x.rolle === "admin"),
|
||||
"keine Creator und keine Admins in der Liste — die Seite ist für Manager und Scouts");
|
||||
|
||||
const pat = p("Patrick");
|
||||
ok(pat?.creator.length === 2, `Patrick betreut zwei Creator (${pat?.creator.length})`);
|
||||
ok(pat?.aufgaben.ueberfaellig === 1,
|
||||
`eine überfällige Aufgabe bei seinen Creators (${pat?.aufgaben.ueberfaellig})`);
|
||||
ok(pat?.aufgaben.laufend === 2, `zwei laufende (${pat?.aufgaben.laufend})`);
|
||||
|
||||
/* DER MANAGER TRÄGT DIE CREATOR SEINER SCOUTS MIT.
|
||||
Ohne das sähe ein Manager mit fünf Scouts aus wie jemand ohne
|
||||
Arbeit -- und genau das wäre die falsche Auskunft. */
|
||||
const mara = p("Mara");
|
||||
ok(mara?.creator.length === 0 && mara?.creator_mittelbar === 2,
|
||||
`Mara betreut niemanden direkt, aber zwei über Patrick `
|
||||
+ `(${mara?.creator.length} / ${mara?.creator_mittelbar})`);
|
||||
ok(mara?.aufgaben.ueberfaellig === 1,
|
||||
`und sieht deren überfällige Aufgabe (${mara?.aufgaben.ueberfaellig})`);
|
||||
ok(mara?.scouts.includes("Patrick"), `mit Namen des Scouts (${mara?.scouts.join(", ")})`);
|
||||
|
||||
/* Die Pipeline gibt es nur bei Scouts. */
|
||||
ok(pat?.pipeline && pat.pipeline.gesamt === 2 && pat.pipeline.uebergeben === 1,
|
||||
`Patricks Pipeline: ${pat?.pipeline?.gesamt} Kontakte, ${pat?.pipeline?.uebergeben} übergeben`);
|
||||
ok(pat?.pipeline?.faellig === 1,
|
||||
`davon einer überfällig im Nachfassen (${pat?.pipeline?.faellig})`);
|
||||
ok(mara?.pipeline === null, "ein Manager hat keine Pipeline — und bekommt auch keine leere");
|
||||
|
||||
/* ---- DIE LÜCKEN ---- */
|
||||
ok(daten.luecken.creator_ohne_betreuung.some((c) => c.name === "Nio"),
|
||||
`Nio steht als Creator ohne Betreuung da `
|
||||
+ `(${daten.luecken.creator_ohne_betreuung.map((c) => c.name).join(", ")})`);
|
||||
ok(daten.luecken.scouts_ohne_manager.some((s) => s.name === "Ben"),
|
||||
`Ben steht als Scout ohne Manager da `
|
||||
+ `(${daten.luecken.scouts_ohne_manager.map((s) => s.name).join(", ")})`);
|
||||
/* GEGENPROBE: Wer versorgt ist, taucht NICHT auf. */
|
||||
ok(!daten.luecken.creator_ohne_betreuung.some((c) => c.name === "Tili")
|
||||
&& !daten.luecken.scouts_ohne_manager.some((s) => s.name === "Patrick"),
|
||||
"und die versorgten stehen nicht darin");
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
4. SPICY MEDIA SIEHT VANVAN NICHT
|
||||
|
||||
Die Hausregel gilt auch hier -- und zwar über dieselbe zentrale
|
||||
Funktion, nicht über eine eigene Fassung.
|
||||
======================================================================= */
|
||||
console.log("\n=== Niemand sieht VanVan außer DogFather");
|
||||
{
|
||||
const alsSpicy = await (await ruf("/workspace/api/team", kSpicy)).json();
|
||||
const text = JSON.stringify(alsSpicy);
|
||||
ok(!text.includes("VanVan"), "in der Spicy-Ansicht steht VanVan nirgends");
|
||||
/* Sie ist Admin und stünde ohnehin nicht in der Liste -- geprüft wird
|
||||
deshalb, dass die Regel überhaupt greift: DogFather sieht sie in
|
||||
der Personenliste, Spicy nicht. */
|
||||
const beiDogi = await (await ruf("/workspace/api/verwaltung/personen", kDogi)).json();
|
||||
const beiSpicy = await (await ruf("/workspace/api/verwaltung/personen", kSpicy)).json();
|
||||
const nDogi = (beiDogi.personen || []).some((x) => x.name === "VanVan");
|
||||
const nSpicy = (beiSpicy.personen || []).some((x) => x.name === "VanVan");
|
||||
ok(nDogi && !nSpicy,
|
||||
`Gegenprobe an der Personenliste: DogFather sieht sie (${nDogi}), Spicy nicht (${nSpicy})`);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
5. IM BROWSER
|
||||
======================================================================= */
|
||||
console.log("\n=== Auf der Seite");
|
||||
{
|
||||
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await pw.chromium.launch();
|
||||
const konsole = [];
|
||||
const seite = await kontext.newPage();
|
||||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
||||
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click(`.rolle[data-rolle="${rolle}"]`);
|
||||
await seite.fill("#code", code);
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 15000 });
|
||||
return { kontext, seite, konsole };
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1200 } });
|
||||
const s = await ctx.newPage();
|
||||
s.on("pageerror", (f) => konsole.push("Absturz: " + f.message));
|
||||
s.on("console", (m) => { if (m.type() === "error") konsole.push("Konsole: " + m.text()); });
|
||||
await s.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await s.click('.rolle[data-rolle="admin"]');
|
||||
await s.fill("#code", "CODE-DOGI-0001");
|
||||
await s.click("button[type=submit]");
|
||||
await s.waitForURL("**/start.html", { timeout: 20000 });
|
||||
await s.waitForTimeout(1200);
|
||||
|
||||
/* Die Kachel steht auf der Startseite und führt hin. */
|
||||
const kachel = await s.evaluate(() => {
|
||||
const k = [...document.querySelectorAll(".kachel")]
|
||||
.find((x) => /Team-Lage/.test(x.textContent));
|
||||
return k ? { da: true, ziel: k.getAttribute("href") || k.dataset.ziel || "",
|
||||
gruppe: k.closest(".gruppe")?.textContent?.slice(0, 40) } : { da: false };
|
||||
});
|
||||
ok(kachel.da, `die Kachel steht auf der Startseite (Gruppe: ${kachel.gruppe})`);
|
||||
|
||||
await s.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" });
|
||||
await s.waitForTimeout(1500);
|
||||
const m = await s.evaluate(() => ({
|
||||
leute: document.querySelectorAll(".tperson").length,
|
||||
zahlen: document.querySelectorAll(".tz").length,
|
||||
luecken: document.querySelectorAll(".tl").length,
|
||||
pipelines: document.querySelectorAll(".tpipe").length,
|
||||
grenze: document.querySelector(".t-kopf__grenze")?.textContent?.trim().slice(0, 60),
|
||||
warnungen: document.querySelectorAll('.tz[data-art="warn"]').length,
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
}));
|
||||
ok(m.leute === 3, `drei Personenkarten (${m.leute})`);
|
||||
ok(m.pipelines === 2, `zwei Pipelines — nur die Scouts (${m.pipelines})`);
|
||||
ok(m.luecken >= 2, `die Lücken stehen oben (${m.luecken} Kästen)`);
|
||||
ok(m.warnungen > 0, `und was liegengeblieben ist, ist markiert (${m.warnungen})`);
|
||||
ok(/Termine, Chats und Dateien/.test(m.grenze || ""),
|
||||
`der Kopf sagt, was NICHT darin steht ("${m.grenze}…")`);
|
||||
ok(m.ueber <= 1, `nichts steht seitlich über (${m.ueber} px)`);
|
||||
if (process.env.BILD === "ja") {
|
||||
await s.screenshot({ path: "_team-gross.png", fullPage: true });
|
||||
}
|
||||
|
||||
/* Am Handy ebenfalls. */
|
||||
const ctx2 = await browser.newContext({ viewport: { width: 390, height: 900 } });
|
||||
const s2 = await ctx2.newPage();
|
||||
await s2.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await s2.click('.rolle[data-rolle="admin"]');
|
||||
await s2.fill("#code", "CODE-DOGI-0001");
|
||||
await s2.click("button[type=submit]");
|
||||
await s2.waitForURL("**/start.html", { timeout: 20000 });
|
||||
await s2.goto(BASIS + "/workspace/team.html", { waitUntil: "networkidle" });
|
||||
await s2.waitForTimeout(1400);
|
||||
const h = await s2.evaluate(() => ({
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
leute: document.querySelectorAll(".tperson").length,
|
||||
breiter: [...document.querySelectorAll(".tperson, .tz, .tl")]
|
||||
.filter((e) => e.getBoundingClientRect().right > 391).length,
|
||||
}));
|
||||
ok(h.ueber <= 1 && h.breiter === 0,
|
||||
`am Handy nichts über (${h.ueber} px, ${h.breiter} zu breit) bei ${h.leute} Karten`);
|
||||
if (process.env.BILD === "ja") {
|
||||
await s2.screenshot({ path: "_team-handy.png", fullPage: true });
|
||||
}
|
||||
await ctx2.close();
|
||||
|
||||
/* ---- LESBAR, NICHT NUR DA (09.09.2026) --------------------------
|
||||
Die Lektion vom Vorlagenbrett: Eine Karte mit sieben Prozent Farbe
|
||||
auf durchsichtigem Grund laesst das Buehnenfoto durch, und dann
|
||||
haengt die Lesbarkeit davon ab, welche Stelle des Fotos gerade
|
||||
dahinterliegt. Dort waren es 3,61:1, wo 4,5 noetig sind.
|
||||
|
||||
Gemessen wird gegen die WIRKLICHE Flaeche: Der Text wird kurz
|
||||
unsichtbar gemacht und die Flaeche fotografiert. */
|
||||
{
|
||||
const leuchtkraft = (r, g, b) => {
|
||||
const f = (w) => { const v = w / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const kontrast = (a, b) => {
|
||||
const l1 = leuchtkraft(...a), l2 = leuchtkraft(...b);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
};
|
||||
async function messen(waehler) {
|
||||
const stelle = await s.evaluate((w) => {
|
||||
const e = document.querySelector(w);
|
||||
if (!e) return null;
|
||||
e.scrollIntoView({ block: "center" });
|
||||
const r = e.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 6) return null;
|
||||
/* Die Farbe wird GEMALT und zurueckgelesen -- getComputedStyle
|
||||
liefert bei color-mix() "color(srgb ...)", und ein Muster
|
||||
ueber die Ziffern liest daraus Unsinn. */
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 1; c.height = 1;
|
||||
const g = c.getContext("2d", { willReadFrequently: true });
|
||||
g.fillStyle = "#000000";
|
||||
g.fillStyle = getComputedStyle(e).color;
|
||||
g.fillRect(0, 0, 1, 1);
|
||||
const px = g.getImageData(0, 0, 1, 1).data;
|
||||
e.dataset.messen = "ja";
|
||||
e.style.color = "transparent";
|
||||
return { x: Math.round(r.x), y: Math.round(r.y),
|
||||
w: Math.round(r.width), h: Math.round(Math.min(r.height, 40)),
|
||||
farbe: [px[0], px[1], px[2]] };
|
||||
}, waehler);
|
||||
if (!stelle) return null;
|
||||
const bild = await s.screenshot({
|
||||
clip: { x: stelle.x, y: stelle.y, width: stelle.w, height: stelle.h } });
|
||||
const hinten = await s.evaluate(async (b64) => {
|
||||
const bild = new Image();
|
||||
await new Promise((r) => { bild.onload = r; bild.src = "data:image/png;base64," + b64; });
|
||||
const c = document.createElement("canvas");
|
||||
c.width = bild.width; c.height = bild.height;
|
||||
const g = c.getContext("2d", { willReadFrequently: true });
|
||||
g.drawImage(bild, 0, 0);
|
||||
const dd = g.getImageData(0, 0, c.width, c.height).data;
|
||||
let r = 0, gr = 0, bl = 0, n = 0;
|
||||
for (let i = 0; i < dd.length; i += 4) { r += dd[i]; gr += dd[i + 1]; bl += dd[i + 2]; n++; }
|
||||
return [Math.round(r / n), Math.round(gr / n), Math.round(bl / n)];
|
||||
}, bild.toString("base64"));
|
||||
await s.evaluate(() => {
|
||||
const e = document.querySelector('[data-messen="ja"]');
|
||||
if (e) { e.style.color = ""; delete e.dataset.messen; }
|
||||
});
|
||||
return { farbe: stelle.farbe, hinten, wert: kontrast(stelle.farbe, hinten) };
|
||||
}
|
||||
for (const [name, waehler] of [
|
||||
["Lücken-Titel", ".tl__titel"],
|
||||
["Lücken-Namen", ".tl__namen"],
|
||||
["Kennzahl", ".tz__wert"],
|
||||
["Beschriftung", ".tz__schild"],
|
||||
["Kennzahl (Warnung)", '.tz[data-art="warn"] .tz__wert'],
|
||||
["Name", ".tperson__name"],
|
||||
["Zuletzt da", ".tperson__zuletzt"],
|
||||
["Grenzsatz", ".t-kopf__grenze"],
|
||||
]) {
|
||||
const m = await messen(waehler);
|
||||
ok(m !== null && m.wert >= 4.5,
|
||||
`${name}: ${m ? m.wert.toFixed(2) + ":1 (" + m.farbe.join(",") + " auf " + m.hinten.join(",") + ")" : "nicht gefunden"}`);
|
||||
}
|
||||
/* GEGENPROBE: ein absichtlich zu dunkler Text MUSS durchfallen. */
|
||||
await s.evaluate(() => { document.querySelector(".tz__wert").style.color = "#131820"; });
|
||||
const schlecht = await messen(".tz__wert");
|
||||
ok(schlecht !== null && schlecht.wert < 4.5,
|
||||
`absichtlich zu dunkler Text fällt durch (${schlecht?.wert.toFixed(2)}:1)`);
|
||||
await s.evaluate(() => { document.querySelector(".tz__wert").style.color = ""; });
|
||||
}
|
||||
|
||||
ok(konsole.length === 0, konsole.length ? "Konsole: " + konsole[0] : "keine Fehler in der Konsole");
|
||||
/* GEGENPROBE zum Lauscher. */
|
||||
await s.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
|
||||
await s.waitForTimeout(400);
|
||||
ok(konsole.some((z) => /Absicht/.test(z)), "ein absichtlicher Fehler wird bemerkt");
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
1. Ein echtes Bild hochladen -- und pruefen, dass es ueberall ankommt
|
||||
================================================================ */
|
||||
console.log("\n=== Das eigene Bild (Screen 5)");
|
||||
const a1 = await anmelden("admin", "CODE-DOGI-0001");
|
||||
|
||||
const hoch = await a1.seite.evaluate(async (b64) => {
|
||||
const roh = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const a = await fetch("/workspace/api/steckbrief/bild", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "image/png" }, body: roh,
|
||||
});
|
||||
return { status: a.status, ...(await a.json().catch(() => ({}))) };
|
||||
}, BILD.toString("base64"));
|
||||
ok(hoch.status === 200 && !!hoch.bild, `ein echtes Bild (600x600) laesst sich hochladen (${hoch.status})`);
|
||||
|
||||
/* Kommt es auf der STARTSEITE in der Begruessung an? Das war der
|
||||
gemeldete Fehler: Dort stand der Buchstabe, obwohl ein Foto da war --
|
||||
und im Server steht seit jeher der Kommentar "es steht in der
|
||||
Kopfleiste JEDER Seite UND IN DER BEGRUESSUNG". Die Absicht war
|
||||
aufgeschrieben, die Haelfte nie gebaut. */
|
||||
await a1.seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
|
||||
await a1.seite.waitForTimeout(1500);
|
||||
const gruss = await a1.seite.evaluate(() => {
|
||||
const p = document.getElementById("plakette");
|
||||
const b = p?.querySelector("img");
|
||||
if (!b) return { bild: false, text: p?.textContent || "" };
|
||||
const r = b.getBoundingClientRect();
|
||||
const pr = p.getBoundingClientRect();
|
||||
const st = getComputedStyle(b);
|
||||
const ps = getComputedStyle(p);
|
||||
return {
|
||||
bild: true,
|
||||
geladen: b.complete && b.naturalWidth > 0,
|
||||
/* Fuellt es die Plakette wirklich aus? Ein Bild, das nur zu einem
|
||||
Viertel drinsteht, sieht aus wie ein Fehler. */
|
||||
fuellt: r.width >= pr.width - 2 && r.height >= pr.height - 2,
|
||||
/* Rund beschnitten -- ohne overflow:hidden ragt ein quadratisches
|
||||
Bild aus dem runden Rahmen. */
|
||||
beschnitten: ps.overflow === "hidden",
|
||||
passung: st.objectFit,
|
||||
kein_text: (p.textContent || "").trim() === "",
|
||||
};
|
||||
});
|
||||
ok(gruss.bild === true, "die Begruessung zeigt das Bild statt des Buchstabens");
|
||||
ok(gruss.geladen === true, " und es ist wirklich geladen (nicht nur ein leerer Rahmen)");
|
||||
ok(gruss.fuellt === true, " es fuellt die Plakette aus");
|
||||
ok(gruss.beschnitten === true, " und ist rund beschnitten (overflow: hidden)");
|
||||
ok(gruss.passung === "cover", ` ohne zu verzerren (object-fit: ${gruss.passung})`);
|
||||
ok(gruss.kein_text === true, " der Buchstabe steht nicht mehr daneben");
|
||||
|
||||
/* ================================================================
|
||||
2. Das Team -- alle Rollen, mit allem, was sie eingetragen haben
|
||||
================================================================ */
|
||||
console.log("\n=== Das Team (Screen 2)");
|
||||
await a1.seite.goto(BASIS + "/workspace/steckbrief.html", { waitUntil: "networkidle" });
|
||||
await a1.seite.waitForTimeout(1600);
|
||||
|
||||
const team = await a1.seite.evaluate(() => {
|
||||
const block = document.getElementById("team-block");
|
||||
const karten = [...document.querySelectorAll(".team-karte")];
|
||||
const sichtbar = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
return {
|
||||
da: block && !block.hidden,
|
||||
/* IST DER STIL ANGEKOMMEN? Die .block__*-Klassen stehen in
|
||||
automation.css und wissen.css -- diese Seite laedt beide nicht.
|
||||
Genau so sind hier schon zweimal Stile ins Leere gelaufen.
|
||||
Gemessen wird deshalb der tatsaechliche Stil, nicht die Klasse. */
|
||||
/* SEIT DEM 07.09.2026 IST DER RAND KEINE `border` MEHR, sondern
|
||||
eine Silhouette mit abgeschnittener Ecke aus module.css. Die
|
||||
alte Bedingung (border >= 1) wurde dadurch zwangsläufig rot,
|
||||
ohne dass ein Stil fehlte. Die FRAGE bleibt aber genau dieselbe
|
||||
und ist so wichtig wie zuvor: Ist der Stil auf DIESER Seite
|
||||
angekommen? Gemessen wird sie jetzt an der neuen Bauweise --
|
||||
Polsterung wie bisher, dazu die Silhouette, die es nur gibt,
|
||||
wenn module.css wirklich geladen wurde. */
|
||||
gestaltet: karten[0] ? (() => {
|
||||
const st = getComputedStyle(karten[0]);
|
||||
return parseFloat(st.paddingLeft) >= 8 && /polygon/.test(st.clipPath);
|
||||
})() : null,
|
||||
anzahl: karten.length,
|
||||
sichtbare: karten.filter(sichtbar).length,
|
||||
gruppen: [...document.querySelectorAll(".team-gruppe__name")].map((e) => e.textContent),
|
||||
leute: karten.map((k) => ({
|
||||
name: k.querySelector(".team-karte__name")?.textContent,
|
||||
rolle: k.dataset.rolle,
|
||||
rollenname: k.querySelector(".team-karte__rolle")?.textContent,
|
||||
ueber: k.querySelector(".team-karte__ueber")?.textContent || null,
|
||||
bild: !!k.querySelector(".team-karte__bild img"),
|
||||
buchstabe: k.querySelector(".team-karte__bild img")
|
||||
? null : (k.querySelector(".team-karte__bild")?.textContent || "").trim(),
|
||||
kanaele: [...k.querySelectorAll(".team-kanal")].map((a) => a.getAttribute("href")),
|
||||
ich: k.dataset.ich === "ja",
|
||||
})),
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
ok(team.da === true, "der Team-Block erscheint");
|
||||
ok(team.gestaltet === true, " und die Stile sind angekommen (Rand und Polsterung gemessen)");
|
||||
ok(team.anzahl === 4 && team.sichtbare === 4,
|
||||
`alle vier Personen stehen da (${team.anzahl}, davon ${team.sichtbare} sichtbar)`);
|
||||
ok(team.gruppen.join(", ") === "Management, Scouting, Creator",
|
||||
`nach Rollen gruppiert: ${team.gruppen.join(", ")}`);
|
||||
|
||||
const dogi = team.leute.find((l) => l.name === "Filipe");
|
||||
const mara = team.leute.find((l) => l.name === "Mara");
|
||||
const pat = team.leute.find((l) => l.name === "Patrick");
|
||||
const tili = team.leute.find((l) => l.name === "Tili");
|
||||
|
||||
ok(!!dogi && !!mara && !!pat && !!tili,
|
||||
"Manager, Scout, DogFather UND Creator sind dabei – nicht nur Creator");
|
||||
ok(dogi?.bild === true, "DogFather wird mit seinem Foto gezeigt");
|
||||
ok(dogi?.ueber === "Manager & CEO dieser Seite",
|
||||
` und mit dem, was er ueber sich geschrieben hat ("${dogi?.ueber}")`);
|
||||
ok(dogi?.ich === true, " seine eigene Karte ist als solche gekennzeichnet");
|
||||
ok(/tiktok\.com/.test(dogi?.kanaele?.[0] || ""),
|
||||
` sein Kanal fuehrt wirklich hin (${dogi?.kanaele?.[0]})`);
|
||||
ok(mara?.ueber === "Ich kuemmere mich um die Vertraege.", "der Manager-Text kommt an");
|
||||
ok(pat?.ueber === "Ich finde neue Leute.", "der Scout-Text kommt an");
|
||||
ok(mara?.rollenname === "Manager" && pat?.rollenname === "Scout",
|
||||
`die Rolle steht dabei (${mara?.rollenname}, ${pat?.rollenname})`);
|
||||
|
||||
/* GEGENPROBEN -- ohne sie waere oben alles auch dann gruen, wenn die
|
||||
Seite einfach ueberall Bilder und Texte hinschriebe. */
|
||||
ok(mara?.bild === false && mara?.buchstabe === "M",
|
||||
`Gegenprobe: wer kein Foto hat, bekommt den Buchstaben ("${mara?.buchstabe}")`);
|
||||
ok(tili?.ueber === null,
|
||||
"Gegenprobe: wer nichts ueber sich geschrieben hat, bekommt keinen Platzhaltersatz");
|
||||
ok(tili?.kanaele?.length === 0,
|
||||
"Gegenprobe: ohne Kanal steht dort auch keiner");
|
||||
ok(mara?.ich === false, "Gegenprobe: fremde Karten sind nicht als 'du' markiert");
|
||||
ok(team.ueber <= 0, `nichts steht seitlich ueber (${team.ueber}px)`);
|
||||
ok(a1.konsole.length === 0,
|
||||
`keine Fehler in der Konsole${a1.konsole.length ? " -> " + a1.konsole.join(" | ") : ""}`);
|
||||
|
||||
await a1.seite.screenshot({ path: "pruef-team.png", fullPage: false });
|
||||
await a1.kontext.close();
|
||||
|
||||
/* ================================================================
|
||||
3. Ein Creator sieht seine Betreuung -- aber nicht die anderen Creator
|
||||
================================================================ */
|
||||
console.log("\n=== Was ein Creator davon sieht");
|
||||
const a2 = await anmelden("creator", "CODE-ANNA-0001");
|
||||
await a2.seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
|
||||
await a2.seite.waitForTimeout(1600);
|
||||
const sicht = await a2.seite.evaluate(() => ({
|
||||
da: !document.getElementById("team-block")?.hidden,
|
||||
namen: [...document.querySelectorAll(".team-karte__name")].map((e) => e.textContent).sort(),
|
||||
}));
|
||||
ok(sicht.da === true, "ein Creator sieht das Team auf seiner Profilseite");
|
||||
ok(sicht.namen.includes("Patrick"), " seinen Scout");
|
||||
ok(sicht.namen.includes("Filipe") && sicht.namen.includes("Mara"),
|
||||
" und das Management");
|
||||
ok(a2.konsole.length === 0,
|
||||
`keine Fehler in der Konsole${a2.konsole.length ? " -> " + a2.konsole.join(" | ") : ""}`);
|
||||
await a2.kontext.close();
|
||||
|
||||
/* Ein zweiter Creator, um zu pruefen, dass Creator einander NICHT sehen.
|
||||
Das ist kein Schoenheitsfehler: Der Server entscheidet das, und wenn
|
||||
sich die Regel je aendert, muss es hier auffallen. */
|
||||
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync("CODE-LUNA-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d2.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run("Luna", "creator", hash, salt, 32768, jetzt);
|
||||
d2.close();
|
||||
|
||||
const a3 = await anmelden("creator", "CODE-ANNA-0001");
|
||||
await a3.seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
|
||||
await a3.seite.waitForTimeout(1600);
|
||||
const fremd = await a3.seite.evaluate(() =>
|
||||
[...document.querySelectorAll(".team-karte__name")].map((e) => e.textContent));
|
||||
ok(!fremd.includes("Luna"),
|
||||
`Gegenprobe: ein Creator sieht einen fremden Creator NICHT (${fremd.join(", ")})`);
|
||||
await a3.kontext.close();
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${geprueft} Prüfungen`);
|
||||
console.log(fehler ? `${fehler} FEHLER` : "ALLES IN ORDNUNG");
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* WAS DARF LIEGENBLEIBEN, UND WAS NICHT (09.09.2026)
|
||||
|
||||
DER FALL, DER DAS AUSGELÖST HAT: Filipe meldete, eine Änderung an der
|
||||
Kopfleiste sei "nicht gemacht". Gemessen war sie auf allen 18 Seiten,
|
||||
in allen fünf Rollen, bei drei Breiten in Ordnung -- und die neuen
|
||||
Dateien lagen nachweislich auf dem Server. Er sah trotzdem den alten
|
||||
Stand.
|
||||
|
||||
Die Ursache: Die HTML-Seiten gingen ohne jede Cache-Angabe hinaus.
|
||||
Ohne `Cache-Control` und ohne `Expires` darf ein Browser heuristisch
|
||||
zwischenspeichern. Und in der HTML stehen die VERSIONSSTEMPEL der
|
||||
Stilvorlagen -- eine alte HTML holt also auch alte CSS- und
|
||||
JS-Dateien. Der ganze Stempelmechanismus hing an einer Datei, die
|
||||
selbst liegenbleiben durfte.
|
||||
|
||||
DIESE PRÜFUNG IST DER GRUND, WARUM ES NICHT WIEDERKOMMT. Sie fragt
|
||||
echte Adressen ab und liest die Kopfzeilen. Eine Regel im Quelltext
|
||||
zu lesen wäre wertlos: Zwischen `setHeaders` und dem, was ankommt,
|
||||
liegen Express, Caddy und Cloudflare.
|
||||
|
||||
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-cache-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||
|
||||
import { notbremse } from "./helfer-notbremse.mjs";
|
||||
const { portMussFreiSein } = await import("./helfer-port.mjs");
|
||||
await portMussFreiSein(4343, "die Zwischenspeicher-Pruefung");
|
||||
|
||||
process.env.PORT = "4343";
|
||||
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
||||
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||
|
||||
const express = (await import("express")).default;
|
||||
const ec = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
||||
await import("./index.js");
|
||||
notbremse(120_000, "pruef-zwischenspeicher");
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const BASIS = "http://127.0.0.1:4343";
|
||||
|
||||
let fehler = 0, geprueft = 0;
|
||||
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
/* ANGEMELDET MESSEN, SONST MISST MAN EINE UMLEITUNG.
|
||||
|
||||
Der erste Anlauf fragte die Workspace-Seiten ohne Anmeldung ab und
|
||||
meldete "keine Cache-Angabe" fuer drei von ihnen -- der Server hatte
|
||||
aber gar keine Seite geschickt, sondern eine Umleitung zur Anmeldung,
|
||||
und eine Umleitung traegt zu Recht keine.
|
||||
|
||||
Das ist genau die Sorte Befund, die teuer ist: Sie sieht aus wie ein
|
||||
Fehler in der Sache und ist einer in der Messung. Die Pruefung
|
||||
braucht ihre Voraussetzung, bevor sie misst. */
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
await fetch(BASIS + "/workspace/api/anmelden", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try { const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||||
}
|
||||
{
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
|
||||
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
|
||||
d.close();
|
||||
}
|
||||
const anAntwort = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }) });
|
||||
const KEKS = (anAntwort.headers.getSetCookie?.() || []).map((x) => x.split(";")[0]).join("; ");
|
||||
if (!KEKS) {
|
||||
console.error("\nKONNTE NICHT ARBEITEN: keine Anmeldung, also keine Seiten zu messen.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const kopf = async (pfad) => {
|
||||
const a = await fetch(BASIS + pfad, { redirect: "manual", headers: { cookie: KEKS } });
|
||||
return { status: a.status, cc: a.headers.get("cache-control") };
|
||||
};
|
||||
|
||||
console.log("\n=== HTML wird immer nachgefragt");
|
||||
for (const p of ["/workspace/start.html", "/workspace/aufgaben.html",
|
||||
"/workspace/kalender.html", "/index.html"]) {
|
||||
const r = await kopf(p);
|
||||
/* DIE NUMMER GEHOERT IN DIE BEDINGUNG. Eine Umleitung traegt zu Recht
|
||||
keine Cache-Angabe -- wer sie mitmisst, sucht einen Fehler, den es
|
||||
nicht gibt. */
|
||||
ok(r.status === 200 && /no-cache|no-store|max-age=0/.test(r.cc || ""),
|
||||
`${p}: HTTP ${r.status}, ${r.cc || "(keine Angabe)"}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Was einen Stempel trägt, darf liegenbleiben");
|
||||
for (const p of ["/workspace/assets/css/start.css", "/workspace/assets/js/kopf.js",
|
||||
"/workspace/assets/css/module.css"]) {
|
||||
const r = await kopf(p);
|
||||
ok(/max-age=\d{6,}/.test(r.cc || ""), `${p}: ${r.cc || "(keine Angabe)"}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Der Service Worker NICHT");
|
||||
{
|
||||
const r = await kopf("/workspace/sw.js");
|
||||
ok(/no-cache/.test(r.cc || ""),
|
||||
`sw.js wird ohne Stempel geladen und muss nachgefragt werden: ${r.cc || "(keine)"}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Bilder: ein Tag, nicht ein Jahr");
|
||||
{
|
||||
const r = await kopf("/workspace/assets/img/marke-husky.webp");
|
||||
ok(r.status === 404 || /max-age=86400/.test(r.cc || ""),
|
||||
`Bild ohne Stempel: ${r.cc || "(keine Angabe)"} (HTTP ${r.status})`);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
GEGENPROBEN
|
||||
|
||||
1. Die Prüfung muss eine FEHLENDE Angabe erkennen -- sonst hätte sie
|
||||
genau den Zustand durchgewinkt, der den Fehler verursacht hat.
|
||||
2. Und sie darf nicht alles durchwinken: "no-cache" bei einer
|
||||
Stilvorlage wäre auch falsch (jeder Aufruf eine Anfrage).
|
||||
--------------------------------------------------------------------- */
|
||||
console.log("\n=== Gegenproben");
|
||||
{
|
||||
const pruefHtml = (cc) => /no-cache|no-store|max-age=0/.test(cc || "");
|
||||
ok(!pruefHtml(null), "eine fehlende Angabe fällt durch");
|
||||
ok(!pruefHtml("public, max-age=31536000"), "und ein langer Speicher an einer HTML ebenfalls");
|
||||
const pruefFest = (cc) => /max-age=\d{6,}/.test(cc || "");
|
||||
ok(!pruefFest("no-cache"), "eine Stilvorlage ohne Speicher fällt ebenfalls auf");
|
||||
ok(pruefFest("public, max-age=31536000, immutable"), "und die richtige Angabe besteht");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
UND DER STEMPEL SELBST: Jede Stilvorlage und jedes Skript, das eine
|
||||
HTML einbindet, MUSS einen tragen. Ohne ihn wäre ein Jahr Speicher
|
||||
ein Jahr alter Stand -- die Regel oben und diese hier gehören
|
||||
zusammen, sonst ist die eine ohne die andere gefährlich.
|
||||
--------------------------------------------------------------------- */
|
||||
console.log("\n=== Jede eingebundene Datei trägt einen Stempel");
|
||||
{
|
||||
const { readdirSync, readFileSync } = await import("node:fs");
|
||||
const { dirname } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace");
|
||||
const seiten = readdirSync(WURZEL).filter((n) => n.endsWith(".html"));
|
||||
const ohne = [];
|
||||
let mit = 0;
|
||||
for (const s of seiten) {
|
||||
const text = readFileSync(join(WURZEL, s), "utf8");
|
||||
for (const t of text.matchAll(/(?:href|src)="(assets\/[^"]*\.(?:css|js|mjs))(\?[^"]*)?"/g)) {
|
||||
if (t[2] && /\?v=\d+/.test(t[2])) mit++;
|
||||
else ohne.push(`${s} → ${t[1]}`);
|
||||
}
|
||||
}
|
||||
ok(mit > 100, `${mit} eingebundene Dateien tragen einen Stempel`);
|
||||
ok(ohne.length === 0,
|
||||
ohne.length ? `OHNE Stempel: ${ohne.slice(0, 4).join(", ")}`
|
||||
: `und keine einzige ohne (${seiten.length} Seiten geprüft)`);
|
||||
}
|
||||
|
||||
console.log(`\n${geprueft} Prüfungen`);
|
||||
console.log(fehler ? `${fehler} FEHLER` : "ALLES IN ORDNUNG");
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler ? 1 : 0);
|
||||
@@ -0,0 +1,319 @@
|
||||
/* =====================================================================
|
||||
workspace-team.js — die Arbeitslage von Managern und Scouts.
|
||||
|
||||
Wunsch Filipe, 09.09.2026: *"so eine kategorie wie über die creator
|
||||
will ich dass nur für die spicy und dogfather rolle auch über manager
|
||||
und scouts gibt ... ich will dass es so ultra krass gut ist dass die
|
||||
spicy und dogfather rolle einen kompletten teil haben mit daten über
|
||||
die arbeit von den manager und scout. keine geheimen sachen also
|
||||
termine, chats und geheime dateien soll auch so bleiben dass keiner."*
|
||||
|
||||
---------------------------------------------------------------------
|
||||
DREI ENTSCHEIDUNGEN, DIE ALLES ANDERE TRAGEN
|
||||
|
||||
1. WAS HIER NICHT VORKOMMT, UND ZWAR GAR NICHT.
|
||||
|
||||
Keine Termine, keine Chats, keine Dateien -- weder Inhalte noch
|
||||
Zählungen. Ausdrücklicher Wunsch, und er ist auch der richtige:
|
||||
Ein Kalender verrät, wann jemand nicht da war; ein Chatzähler
|
||||
verrät, mit wem jemand oft spricht. Beides ist Privatsache und
|
||||
beantwortet keine Frage, die eine Agentur wirklich hat.
|
||||
|
||||
Die Abfragen unten rühren `termine`, `chat_*` und `dateien` nicht
|
||||
an. Das ist keine Zusicherung im Kommentar, sondern eine
|
||||
Eigenschaft des Codes -- pruef-team.mjs liest diese Datei und
|
||||
schlägt an, wenn eine dieser Tabellen darin auftaucht.
|
||||
|
||||
2. SEGMENTIEREN, NICHT MITTELN.
|
||||
|
||||
Aus der Recherche: Ein Durchschnitt versteckt genau die Person,
|
||||
bei der es klemmt. Zwei Scouts mit je 6 Creators und einer mit 18
|
||||
ergeben einen Schnitt von 10 -- und der sagt nichts. Deshalb wird
|
||||
hier JEDE Person einzeln ausgegeben, und die Oberfläche markiert
|
||||
die Ausreißer.
|
||||
|
||||
3. ES IST EINE ARBEITSLAGE, KEINE ÜBERWACHUNG.
|
||||
|
||||
Auch das steht so in der Fachliteratur: Der Zweck ist, Stellen zu
|
||||
finden, an denen Unterstützung fehlt -- nicht, Leute zu messen.
|
||||
Deshalb sind die Kennzahlen so gewählt, dass sie auf ZUSTÄNDE
|
||||
zeigen (unbetreute Creator, überfällige Aufgaben, liegengebliebene
|
||||
Kontakte) und nicht auf Anwesenheit oder Fleiß.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
WER DARF HINEIN
|
||||
|
||||
Nur `spicy` und `admin`. Und Spicy Media sieht DogFathers zweiten
|
||||
Zugang nicht -- dieselbe zentrale Regel wie überall
|
||||
(`verborgeneIds`), nicht eine eigene Fassung davon.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, sitzungLesen, verborgeneIds, heuteLokal, ROLLEN_SORTIERUNG,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const teamRouter = express.Router();
|
||||
|
||||
/* Wer diesen ganzen Bereich sehen darf. EINE Menge, an EINER Stelle --
|
||||
die Seite fragt dieselbe über /workspace/api/ich ab. */
|
||||
export const TEAM_ROLLEN = new Set(["spicy", "admin"]);
|
||||
|
||||
function nurLeitung(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
/* 404 und nicht 403: Was man nicht sehen darf, soll es fuer den
|
||||
Fragenden gar nicht geben. Ein 403 bestaetigt, dass es den Bereich
|
||||
gibt -- und genau das ist die Auskunft, die man nicht geben will. */
|
||||
if (!TEAM_ROLLEN.has(person.rolle)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
teamRouter.use("/workspace/api/team", nurLeitung);
|
||||
|
||||
const zahl = (x) => Number(x) || 0;
|
||||
|
||||
/* Tage zwischen zwei Zeitstempeln, auf eine Stelle gerundet. */
|
||||
function tageZwischen(a, b) {
|
||||
if (!a || !b) return null;
|
||||
const d = (new Date(b) - new Date(a)) / 86400000;
|
||||
return Number.isFinite(d) && d >= 0 ? Math.round(d * 10) / 10 : null;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
DIE ARBEITSLAGE
|
||||
===================================================================== */
|
||||
teamRouter.get("/workspace/api/team", (req, res) => {
|
||||
try {
|
||||
const d = db();
|
||||
const heute = heuteLokal();
|
||||
const weg = new Set(verborgeneIds(req.person));
|
||||
|
||||
/* ---- 1. Wer ist überhaupt da ---------------------------------- */
|
||||
const leute = d.prepare(`
|
||||
SELECT id, name, rolle, aktiv, erstellt, letzter_login, bild
|
||||
FROM personen
|
||||
WHERE rolle IN ('manager','scout')
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all()
|
||||
.filter((p) => !weg.has(p.id));
|
||||
|
||||
/* ---- 2. Wen betreut wer --------------------------------------- */
|
||||
/* EIN Creator kann nur EINEN Betreuer haben (betreuung ist so
|
||||
gebaut). Ein Manager bekommt zusätzlich die Creator, die an
|
||||
seinen Scouts hängen -- das ist seine mittelbare Verantwortung,
|
||||
und ohne sie sähe ein Manager mit fünf Scouts aus wie jemand
|
||||
ohne Arbeit. */
|
||||
const betreut = d.prepare(`
|
||||
SELECT b.betreuer_id AS wer, p.id AS creator_id, p.name AS creator, p.aktiv
|
||||
FROM betreuung b JOIN personen p ON p.id = b.creator_id`).all();
|
||||
const scoutsVon = d.prepare(`
|
||||
SELECT z.manager_id AS wer, p.id AS scout_id, p.name AS scout
|
||||
FROM scout_zuteilung z JOIN personen p ON p.id = z.scout_id`).all();
|
||||
|
||||
const direkt = new Map(); /* betreuer_id -> [creator] */
|
||||
for (const z of betreut) {
|
||||
if (!direkt.has(z.wer)) direkt.set(z.wer, []);
|
||||
direkt.get(z.wer).push({ id: z.creator_id, name: z.creator, aktiv: !!z.aktiv });
|
||||
}
|
||||
const meineScouts = new Map(); /* manager_id -> [scout] */
|
||||
for (const z of scoutsVon) {
|
||||
if (!meineScouts.has(z.wer)) meineScouts.set(z.wer, []);
|
||||
meineScouts.get(z.wer).push({ id: z.scout_id, name: z.scout });
|
||||
}
|
||||
|
||||
/* ---- 3. Aufgaben, nach Creator gruppiert ---------------------- */
|
||||
/* Eine Abfrage fuer alle -- nicht eine je Person. Bei zwoelf Leuten
|
||||
waeren das zwoelf Abfragen fuer dieselbe Tabelle. */
|
||||
const aufgabenJeCreator = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT creator_id,
|
||||
SUM(CASE WHEN status IN ('offen','arbeit','review') THEN 1 ELSE 0 END) AS laufend,
|
||||
SUM(CASE WHEN status IN ('offen','arbeit','review')
|
||||
AND frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS ueberfaellig,
|
||||
SUM(CASE WHEN status = 'erledigt' THEN 1 ELSE 0 END) AS erledigt,
|
||||
SUM(CASE WHEN status = 'abgebrochen' THEN 1 ELSE 0 END) AS abgebrochen,
|
||||
SUM(CASE WHEN status = 'erledigt' AND erledigt_am >= ? THEN 1 ELSE 0 END) AS erledigt30
|
||||
FROM aufgaben WHERE creator_id IS NOT NULL
|
||||
GROUP BY creator_id`).all(heute, vorTagen(30))) {
|
||||
aufgabenJeCreator.set(z.creator_id, z);
|
||||
}
|
||||
|
||||
/* Durchlaufzeit: von der Anlage bis zum Abhaken. Nur die letzten
|
||||
neunzig Tage -- eine Zahl aus dem letzten Jahr beschreibt eine
|
||||
Arbeitsweise, die es vielleicht nicht mehr gibt. */
|
||||
const durchlauf = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT creator_id, erstellt, erledigt_am FROM aufgaben
|
||||
WHERE status = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am >= ?`)
|
||||
.all(vorTagen(90))) {
|
||||
const t = tageZwischen(z.erstellt, z.erledigt_am);
|
||||
if (t === null) continue;
|
||||
if (!durchlauf.has(z.creator_id)) durchlauf.set(z.creator_id, []);
|
||||
durchlauf.get(z.creator_id).push(t);
|
||||
}
|
||||
|
||||
/* ---- 4. Fortschritt der betreuten Creator --------------------- */
|
||||
const startcheckJeCreator = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT creator_id, COUNT(*) AS gesamt,
|
||||
SUM(CASE WHEN bewertung IS NOT NULL AND bewertung <> '' AND bewertung <> 'offen'
|
||||
THEN 1 ELSE 0 END) AS erledigt
|
||||
FROM startcheck GROUP BY creator_id`).all()) {
|
||||
startcheckJeCreator.set(z.creator_id, z);
|
||||
}
|
||||
const punkteJeCreator = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT creator_id, COUNT(*) AS gesamt,
|
||||
SUM(CASE WHEN status = 'offen' THEN 1 ELSE 0 END) AS offen
|
||||
FROM eintraege WHERE creator_id IS NOT NULL GROUP BY creator_id`).all()) {
|
||||
punkteJeCreator.set(z.creator_id, z);
|
||||
}
|
||||
|
||||
/* ---- 5. Zahlen der betreuten Creator (30 Tage) ---------------- */
|
||||
const leistungJeCreator = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT creator_id, SUM(diamanten) AS diamanten, SUM(dauer_min) AS minuten,
|
||||
COUNT(*) AS tage, MAX(tag) AS letzter
|
||||
FROM leistung WHERE tag >= ? GROUP BY creator_id`).all(vorTagen(30))) {
|
||||
leistungJeCreator.set(z.creator_id, z);
|
||||
}
|
||||
|
||||
/* ---- 6. Die Pipeline der Scouts ------------------------------- */
|
||||
const leadsJeScout = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT scout_id, COUNT(*) AS gesamt,
|
||||
/* DIE STATUS STEHEN IN DER TABELLE, NICHT IN MEINEM KOPF.
|
||||
Erster Anlauf war ('neu','kontakt','gespraech') -- "kontakt"
|
||||
gibt es gar nicht, und die Datenbank hat es mit einer
|
||||
CHECK-Bedingung sofort abgelehnt. Gut so: Ohne die
|
||||
Bedingung waere "offen" still zu klein gewesen, und
|
||||
niemand haette es gemerkt. */
|
||||
SUM(CASE WHEN status IN ('neu','angesprochen','gespraech','interessiert')
|
||||
THEN 1 ELSE 0 END) AS offen,
|
||||
SUM(CASE WHEN status = 'uebergeben' THEN 1 ELSE 0 END) AS uebergeben,
|
||||
SUM(CASE WHEN status = 'abgelehnt' THEN 1 ELSE 0 END) AS abgelehnt,
|
||||
SUM(CASE WHEN naechster_followup IS NOT NULL AND naechster_followup < ?
|
||||
AND status NOT IN ('uebergeben','abgelehnt') THEN 1 ELSE 0 END) AS faellig
|
||||
FROM leads WHERE scout_id IS NOT NULL GROUP BY scout_id`).all(heute)) {
|
||||
leadsJeScout.set(z.scout_id, z);
|
||||
}
|
||||
/* Wie lange von der Anlage bis zur Übergabe? Das ist die Zahl, die
|
||||
eine Pipeline wirklich beschreibt -- eine Menge offener Kontakte
|
||||
sagt nichts, wenn keiner davon je ankommt. */
|
||||
const uebergabeJeScout = new Map();
|
||||
for (const z of d.prepare(`
|
||||
SELECT scout_id, erstellt, uebergeben_am FROM leads
|
||||
WHERE uebergeben_am IS NOT NULL AND scout_id IS NOT NULL`).all()) {
|
||||
const t = tageZwischen(z.erstellt, z.uebergeben_am);
|
||||
if (t === null) continue;
|
||||
if (!uebergabeJeScout.has(z.scout_id)) uebergabeJeScout.set(z.scout_id, []);
|
||||
uebergabeJeScout.get(z.scout_id).push(t);
|
||||
}
|
||||
|
||||
/* ---- 7. Alles zu einer Zeile je Person zusammensetzen --------- */
|
||||
const mittel = (liste) => (liste && liste.length
|
||||
? Math.round((liste.reduce((s, x) => s + x, 0) / liste.length) * 10) / 10 : null);
|
||||
|
||||
const zeilen = leute.map((p) => {
|
||||
const eigene = direkt.get(p.id) || [];
|
||||
const scouts = meineScouts.get(p.id) || [];
|
||||
/* Mittelbare Creator: die der eigenen Scouts. Ohne Doppelung,
|
||||
falls ein Manager einen Creator auch direkt betreut. */
|
||||
const mittelbar = [];
|
||||
const schon = new Set(eigene.map((c) => c.id));
|
||||
for (const s of scouts) {
|
||||
for (const c of direkt.get(s.id) || []) {
|
||||
if (!schon.has(c.id)) { schon.add(c.id); mittelbar.push(c); }
|
||||
}
|
||||
}
|
||||
const alleCreator = [...eigene, ...mittelbar];
|
||||
|
||||
const summe = (karte, feld) => alleCreator
|
||||
.reduce((s, c) => s + zahl((karte.get(c.id) || {})[feld]), 0);
|
||||
const zeiten = alleCreator.flatMap((c) => durchlauf.get(c.id) || []);
|
||||
|
||||
const scGesamt = summe(startcheckJeCreator, "gesamt");
|
||||
const scFertig = summe(startcheckJeCreator, "erledigt");
|
||||
const lead = leadsJeScout.get(p.id) || {};
|
||||
|
||||
return {
|
||||
id: p.id, name: p.name, rolle: p.rolle, aktiv: !!p.aktiv,
|
||||
seit: p.erstellt, letzter_login: p.letzter_login,
|
||||
bild: p.bild ? `/workspace/api/steckbrief/bild/${p.bild}` : null,
|
||||
|
||||
creator: eigene.map((c) => ({ id: c.id, name: c.name, aktiv: c.aktiv })),
|
||||
creator_mittelbar: mittelbar.length,
|
||||
scouts: scouts.map((s) => s.name),
|
||||
|
||||
aufgaben: {
|
||||
laufend: summe(aufgabenJeCreator, "laufend"),
|
||||
ueberfaellig: summe(aufgabenJeCreator, "ueberfaellig"),
|
||||
erledigt30: summe(aufgabenJeCreator, "erledigt30"),
|
||||
abgebrochen: summe(aufgabenJeCreator, "abgebrochen"),
|
||||
durchlauf_tage: mittel(zeiten),
|
||||
},
|
||||
fortschritt: {
|
||||
startcheck_gesamt: scGesamt,
|
||||
startcheck_fertig: scFertig,
|
||||
punkte_gesamt: summe(punkteJeCreator, "gesamt"),
|
||||
punkte_offen: summe(punkteJeCreator, "offen"),
|
||||
},
|
||||
zahlen: {
|
||||
diamanten30: summe(leistungJeCreator, "diamanten"),
|
||||
minuten30: summe(leistungJeCreator, "minuten"),
|
||||
livetage30: summe(leistungJeCreator, "tage"),
|
||||
},
|
||||
pipeline: p.rolle === "scout" ? {
|
||||
gesamt: zahl(lead.gesamt), offen: zahl(lead.offen),
|
||||
uebergeben: zahl(lead.uebergeben), abgelehnt: zahl(lead.abgelehnt),
|
||||
faellig: zahl(lead.faellig),
|
||||
uebergabe_tage: mittel(uebergabeJeScout.get(p.id)),
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
|
||||
/* ---- 8. Die Lücken ------------------------------------------- */
|
||||
/* Das ist der wichtigste Teil der ganzen Seite. Eine Kennzahl sagt,
|
||||
wie es läuft; eine Lücke sagt, wo etwas fehlt -- und nur das
|
||||
Zweite kann man heute abstellen. */
|
||||
const alleBetreut = new Set(betreut.map((z) => z.creator_id));
|
||||
const ohneBetreuung = d.prepare(`
|
||||
SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name`)
|
||||
.all().filter((c) => !alleBetreut.has(c.id) && !weg.has(c.id));
|
||||
|
||||
const zugeteilteScouts = new Set(scoutsVon.map((z) => z.scout_id));
|
||||
const scoutsOhneManager = leute
|
||||
.filter((p) => p.rolle === "scout" && p.aktiv && !zugeteilteScouts.has(p.id))
|
||||
.map((p) => ({ id: p.id, name: p.name }));
|
||||
|
||||
const ohneCreator = zeilen
|
||||
.filter((z) => z.aktiv && !z.creator.length && !z.creator_mittelbar)
|
||||
.map((z) => ({ id: z.id, name: z.name, rolle: z.rolle }));
|
||||
|
||||
res.json({
|
||||
stand: heute,
|
||||
leute: zeilen,
|
||||
luecken: {
|
||||
creator_ohne_betreuung: ohneBetreuung,
|
||||
scouts_ohne_manager: scoutsOhneManager,
|
||||
ohne_zuteilung: ohneCreator,
|
||||
},
|
||||
/* Was hier ausdruecklich NICHT drinsteht -- die Oberflaeche sagt
|
||||
es dem Betrachter, damit niemand danach sucht. */
|
||||
ausgenommen: ["Termine", "Chats", "Dateien"],
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Team-Lage:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Ein Datum vor N Tagen, in Ortszeit als "JJJJ-MM-TT".
|
||||
* NICHT toISOString(): Das liefert UTC und damit nachts den Vortag --
|
||||
* derselbe Fehler, der im Haus schon zweimal eine Zahl verschoben hat. */
|
||||
function vorTagen(n) {
|
||||
const d = new Date(Date.now() - n * 86400000);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
|
||||
+ `-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -1982,6 +1982,19 @@ const GESCHUETZT = {
|
||||
"/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator"],
|
||||
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
|
||||
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
|
||||
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
|
||||
|
||||
Filipe: "so eine kategorie wie ueber die creator will ich dass nur
|
||||
fuer die spicy und dogfather rolle auch ueber manager und scouts
|
||||
gibt."
|
||||
|
||||
Nur diese beiden Rollen. Ein Manager sieht seine eigene Zeile hier
|
||||
ausdruecklich NICHT -- das ist kein Versehen: Eine Uebersicht ueber
|
||||
die Arbeit der Kollegen gehoert der Leitung, nicht der Reihe. Der
|
||||
Schutz steht ohnehin in der Schnittstelle (workspace-team.js);
|
||||
diese Zeile sorgt nur dafuer, dass niemand vor einer leeren Seite
|
||||
steht. */
|
||||
"/workspace/team.html": ["spicy", "admin"],
|
||||
"/workspace/calls.html": null,
|
||||
"/workspace/startcheck.html": null,
|
||||
"/workspace/wissen.html": null,
|
||||
|
||||
Reference in New Issue
Block a user