Files
DogFatherGitandClaude Opus 5 b45de940e8 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]>
2026-09-09 15:42:14 +02:00

320 lines
14 KiB
JavaScript

/* =====================================================================
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")}`;
}