diff --git a/server/index.js b/server/index.js index bf486894..f5a904bc 100644 --- a/server/index.js +++ b/server/index.js @@ -42,6 +42,7 @@ import { teamRouter } from "./workspace-team.js"; import { schulungRouter } from "./workspace-schulung.js"; import { ampelRouter } from "./workspace-ampel.js"; import { checklisteRouter } from "./workspace-checkliste.js"; +import { teamlageRouter } from "./workspace-teamlage.js"; import { pushRouter, pushStarten } from "./workspace-push.js"; import { chatRouter } from "./workspace-chat.js"; import { leistungRouter } from "./workspace-leistung.js"; @@ -211,6 +212,7 @@ app.use(teamRouter); app.use(schulungRouter); app.use(ampelRouter); app.use(checklisteRouter); +app.use(teamlageRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); diff --git a/server/pruef-modi-checkliste.mjs b/server/pruef-modi-checkliste.mjs new file mode 100644 index 00000000..d2860e06 --- /dev/null +++ b/server/pruef-modi-checkliste.mjs @@ -0,0 +1,238 @@ +/* ===================================================================== + DIE CHECKLISTEN EINES MODIS + + Wunsch Filipe (10.09.2026): *"diese kategorien mussen auf dieser + seite auch noch auf die modis perfektionniert werden, da mussen + lauter sachen sein die vanvan und ich danach verteilen können und da + anpassen können ob gut oder nicht."* + + VIER FRAGEN: + + 1. Bekommt ein Modi SEINEN Satz -- nicht den der Creator? + 2. Sieht er seine EIGENE Liste, ohne jemanden auswaehlen zu muessen? + 3. Kann die DogFather-Rolle ihn auswaehlen und bewerten? + 4. Bekommt Spicy Media ihn NICHT in die Auswahl? + + Die vierte ist die heikelste. `darfCreator` sagt bei siehtAlles() + pauschal ja -- und darin steckt auch Spicy Media. Wer die + Modi-Auswahl daran haengt, oeffnet sie ihr nebenbei mit, ohne dass an + der Stelle etwas davon steht. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-mcheck-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4369"; +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"); +notbremse(180_000, "pruef-modi-checkliste"); +await new Promise((r) => setTimeout(r, 800)); +const BASIS = "http://127.0.0.1:4369"; + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +let offen = 0; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const gh = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", gh, jetzt); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", gh).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idVan = anlegen("VanVan", "admin", "CODE-VAN-0001"); +const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001"); +const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idModi = anlegen("Marina", "modi", "CODE-MODI-0001"); +d.close(); +void idDogi; + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), + }); + const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + return { status: a.status, keks }; +} +async function hole(keks, pfad, aufruf = {}) { + const a = await fetch(BASIS + pfad, { + ...aufruf, headers: { ...(aufruf.headers || {}), cookie: keks }, + }); + return { status: a.status, daten: await a.json().catch(() => null) }; +} +const jsonKopf = { "Content-Type": "application/json" }; + +const modiAn = await anmelden("creator", "CODE-MODI-0001"); +const dogiAn = await anmelden("admin", "CODE-DOGI-0001"); +const vanAn = await anmelden("admin", "CODE-VAN-0001"); +const spicyAn = await anmelden("spicy", "CODE-SPIC-0001"); +const creatAn = await anmelden("creator", "CODE-ANNA-0001"); + +/* ---------- 1. Sein eigener Satz ------------------------------------- */ +console.log("\n=== 1. Der Modi bekommt seinen eigenen Punktesatz ===\n"); +const BEREICHE = ["live", "community", "technik"]; +let ersterPunkt = null; +for (const b of BEREICHE) { + const a = await hole(modiAn.keks, `/workspace/api/checkliste/${b}`); + if (a.status !== 200) { unklar(`${b}: nicht lesbar (${a.status})`); continue; } + const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []); + /* DIE ZAHL IN DIE BEDINGUNG: Eine leere Liste wuerde sonst als + "keine Creator-Punkte dabei" durchgehen. */ + ok(punkte.length > 0 && punkte.every((p) => p.schluessel.startsWith("m-")), + `${b.padEnd(10)} ${punkte.length} Punkte, alle aus seinem Satz`); + if (b === "live" && punkte.length) ersterPunkt = punkte[0].schluessel; + /* Und er sieht SEINE Liste, ohne jemanden auszuwaehlen. */ + ok(a.daten?.creator_id === idModi, + `${b.padEnd(10)} es ist seine eigene Liste (${a.daten?.creator_id})`); + ok(a.daten?.darf_bewerten === false, + `${b.padEnd(10)} bewerten darf er sich nicht selbst`); +} + +/* GEGENPROBE: Ein Creator bekommt weiterhin SEINEN Satz -- sonst waere + oben auch gruen, wenn ich allen denselben gegeben haette. */ +{ + const a = await hole(creatAn.keks, "/workspace/api/checkliste/live"); + const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []); + ok(punkte.length > 0 && punkte.every((p) => !p.schluessel.startsWith("m-")), + `der Creator bekommt unveraendert seinen Satz (${punkte.length} Punkte)`); +} + +/* ---------- 2. DogFather und VanVan waehlen ihn aus ------------------ */ +console.log("\n=== 2. DogFather und VanVan sehen ihn ===\n"); +for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) { + const a = await hole(an.keks, "/workspace/api/checkliste/live"); + const drin = (a.daten?.creatoren || []).some((z) => z.id === idModi); + ok(drin, `${name.padEnd(10)} hat ihn in der Auswahl`); + const b = await hole(an.keks, `/workspace/api/checkliste/live?creator_id=${idModi}`); + const punkte = (b.daten?.gruppen || []).flatMap((g) => g.punkte || []); + ok(punkte.length > 0 && punkte.every((p) => p.schluessel.startsWith("m-")), + `${name.padEnd(10)} bekommt SEINEN Satz, nicht den der Creator (${punkte.length})`); + ok(b.daten?.darf_bewerten === true, `${name.padEnd(10)} darf bewerten`); +} + +/* ---------- 3. Bewerten ---------------------------------------------- */ +console.log("\n=== 3. Gut oder verbessern ===\n"); +if (!ersterPunkt) { + unklar("ohne Punkt laesst sich das Bewerten nicht pruefen"); +} else { + const a = await hole(dogiAn.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, { + method: "PUT", headers: jsonKopf, + body: JSON.stringify({ stufe: "gut", begruendung: "sauber gemacht", creator_id: idModi }), + }); + ok(a.status === 200, `DogFather setzt "gut" (${a.status})`); + + const b = await hole(modiAn.keks, "/workspace/api/checkliste/live"); + ok(b.daten?.stand?.[ersterPunkt]?.stufe === "gut", + `der Modi sieht den Stand (${b.daten?.stand?.[ersterPunkt]?.stufe})`); + + const c = await hole(vanAn.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, { + method: "PUT", headers: jsonKopf, + body: JSON.stringify({ stufe: "verbessern", begruendung: "beim nächsten Mal früher", creator_id: idModi }), + }); + ok(c.status === 200, `VanVan setzt "verbessern" (${c.status})`); + + /* Und er selbst kann es NICHT umstellen -- sonst waere die Bewertung + eine Selbsteinschaetzung. */ + const e = await hole(modiAn.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, { + method: "PUT", headers: jsonKopf, body: JSON.stringify({ stufe: "gut" }), + }); + ok(e.status === 403, `er selbst kann sie nicht umstellen (${e.status})`); +} + +/* ---------- 4. Spicy Media geht das nichts an ------------------------ */ +console.log("\n=== 4. Spicy Media bekommt ihn nicht ===\n"); +{ + const a = await hole(spicyAn.keks, "/workspace/api/checkliste/live"); + const drin = (a.daten?.creatoren || []).some((z) => z.id === idModi); + ok(!drin, + `nicht in der Auswahl (${(a.daten?.creatoren || []).length} Personen: ` + + `${(a.daten?.creatoren || []).map((z) => z.name).join(", ")})`); + /* GEGENPROBE: Den Creator hat sie sehr wohl -- sonst waere der Haken + darueber auch dann gruen, wenn die Auswahl ueberhaupt leer bliebe. */ + ok((a.daten?.creatoren || []).some((z) => z.id === idCreat), + "den Creator dagegen schon"); + /* Und ueber die Adresse auch nicht. Eine Regel, die nur in der + Auswahlliste gilt, ist keine. */ + const b = await hole(spicyAn.keks, `/workspace/api/checkliste/live?creator_id=${idModi}`); + ok(b.status === 404, `und ueber die Adresse ebenfalls nicht (${b.status})`); + const c = await hole(spicyAn.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, { + method: "PUT", headers: jsonKopf, + body: JSON.stringify({ stufe: "gut", creator_id: idModi }), + }); + ok(c.status === 404, `bewerten erst recht nicht (${c.status})`); +} + + +/* ---------- 5. Die gemeinsame Lage ----------------------------------- */ +console.log("\n=== 5. Die Seite für DogFather und VanVan ===\n"); +{ + for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) { + const a = await hole(an.keks, "/workspace/api/teamlage"); + if (a.status !== 200) { unklar(`${name}: nicht lesbar (${a.status})`); continue; } + const eintrag = (a.daten?.modis || []).find((m) => m.id === idModi); + ok(!!eintrag, `${name.padEnd(10)} sieht den Modi in der Lage`); + /* Die Zahlen muessen zum Punktesatz passen -- eine Uebersicht, die + andere Zahlen zeigt als die Seite dahinter, ist schlimmer als + keine. */ + ok(eintrag?.bereiche?.live?.gesamt === 40, + `${name.padEnd(10)} und die Gesamtzahl stimmt (${eintrag?.bereiche?.live?.gesamt})`); + /* Ein Punkt wurde in Abschnitt 3 auf "verbessern" gesetzt. */ + ok(eintrag?.bereiche?.live?.verbessern === 1 + && eintrag?.bereiche?.live?.offen === 39, + `${name.padEnd(10)} der bewertete Punkt ist abgezogen ` + + `(${eintrag?.bereiche?.live?.verbessern} verbessern, ${eintrag?.bereiche?.live?.offen} offen)`); + /* Und die Kachel dorthin bekommt sie auch. */ + const b = await hole(an.keks, "/workspace/api/ich"); + const kachel = (b.daten?.bereiche_zusatz || []).some((k) => k.ziel === "teamlage.html"); + ok(kachel, `${name.padEnd(10)} hat die Kachel auf der Startseite`); + } +} + +/* ---------- 6. Für alle anderen gibt es sie nicht -------------------- */ +console.log("\n=== 6. Und für alle anderen gibt es sie nicht ===\n"); +for (const [name, an] of [["Spicy Media", spicyAn], ["Creator", creatAn], ["Modi", modiAn]]) { + const a = await hole(an.keks, "/workspace/api/teamlage"); + const b = await hole(an.keks, "/workspace/api/gibtesnicht"); + ok(a.status === 404, + `${name.padEnd(12)} bekommt 404 -- wie bei einer Adresse, die es nicht gibt (${a.status}/${b.status})`); + const c = await hole(an.keks, "/workspace/api/ich"); + ok((c.daten?.bereiche_zusatz || []).length === 0, + `${name.padEnd(12)} bekommt auch die Kachel nicht`); +} + +/* GEGENPROBE ZUR KACHEL: Sie ist ueberhaupt vorhanden -- sonst waeren + die drei Haken darueber auch dann gruen, wenn es sie gar nicht gibt. */ +{ + const a = await hole(dogiAn.keks, "/workspace/api/ich"); + ok((a.daten?.bereiche_zusatz || []).length === 1, + `bei DogFather ist genau eine Zusatzkachel da (${(a.daten?.bereiche_zusatz || []).length})`); +} +console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- ` + + `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-checkliste.js b/server/workspace-checkliste.js index 25b171cf..6c72a269 100644 --- a/server/workspace-checkliste.js +++ b/server/workspace-checkliste.js @@ -36,7 +36,9 @@ import express from "express"; import { db, sitzungLesen, istLeitung, istDogFather, siehtAlles, darfCreator, betreuteIds, protokolliere, echteIp, + siehtModis, } from "./workspace.js"; +import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; import { CONTENT_IDEEN, LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE, } from "./workspace-vorlagen.js"; @@ -121,9 +123,45 @@ export const LISTEN = { /* Alle gültigen Schlüssel je Bereich -- damit niemand einen Stand für einen Punkt setzen kann, den es gar nicht gibt. */ +/* ===================================================================== + ZWEI PUNKTESAETZE JE BEREICH (10.09.2026) + + Wunsch Filipe: *"diese kategorien mussen auf dieser seite auch noch + auf die modis perfektionniert werden."* + + Die Punkte in LISTEN sind fuer CREATOR geschrieben -- "Upload + messen", "Sendeplan festlegen". Ein Modi sendet nicht, er moderiert; + ihm dieselbe Liste vorzulegen hiesse, ihn an Dingen zu messen, die + nicht seine Arbeit sind. Sein Satz steht in workspace-modi-punkte.js. + + DIE EINTEILUNG BLEIBT DIESELBE (Vor/Waehrend/Nach usw.) -- getauscht + werden nur die Punkte darin. Zwei verschiedene Einteilungen waeren + zwei Dinge zum Lernen statt einem, und die Oberflaeche kaeme ohne + Umbau nicht damit zurecht. + ===================================================================== */ +function gruppenFuer(bereich, fuerModi) { + const liste = LISTEN[bereich]; + if (!liste) return null; + const satz = fuerModi ? MODI_PUNKTE[bereich] : null; + if (!satz) return liste.gruppen; + /* Eine Gruppe ohne Punkte faellt heraus statt leer dazustehen -- ein + Abschnitt mit einer Ueberschrift und nichts darunter sieht aus wie + ein Fehler. */ + return liste.gruppen + .map((g) => ({ ...g, punkte: satz[g.schluessel] || [] })) + .filter((g) => g.punkte.length); +} + +/* BEIDE SAETZE sind gueltige Schluessel. Stuenden hier nur die + Creator-Punkte, liesse sich der Stand eines Modi-Punktes nicht + setzen -- die Liste kaeme an, und jeder Klick darauf brachte einen + 404. Genau die Sorte Fehler, die man erst beim Benutzen merkt. */ const GUELTIG = {}; for (const [bereich, liste] of Object.entries(LISTEN)) { GUELTIG[bereich] = new Set(liste.gruppen.flatMap((g) => g.punkte.map((p) => p.schluessel))); + for (const punkte of Object.values(MODI_PUNKTE[bereich] || {})) { + for (const p of punkte) GUELTIG[bereich].add(p.schluessel); + } } let bereit = false; @@ -183,12 +221,37 @@ function gleicheHerkunft(req, res, next) { next(); } -/** Um welchen Creator geht es -- und darf die Person das? */ +/** Um WESSEN Liste geht es -- und darf die Person das? + * + * Ein Creator und ein Modi arbeiten beide an ihrer EIGENEN Liste; sie + * koennen keine fremde waehlen. Alle anderen waehlen aus, wen sie + * betreuen. + * + * DIE SPALTE HEISST WEITERHIN creator_id. Das ist Geschichte, kein + * Inhalt: Sie haelt eine Personennummer. Sie umzubenennen hiesse, acht + * Abfragen und eine Tabelle anzufassen, damit ein Wort besser passt -- + * und jede davon waere eine Gelegenheit fuer einen Fehler. + */ function creatorAus(req) { - if (req.person.rolle === "creator") return req.person.id; + if (req.person.rolle === "creator" || req.person.rolle === "modi") return req.person.id; const id = Number(req.body?.creator_id || req.query?.creator_id) || 0; if (!id) return 0; - return darfCreator(req.person, id) ? id : -1; /* -1 = verboten */ + return darfPerson(req.person, id) ? id : -1; /* -1 = verboten */ +} + +/** Darf diese Person die Liste jener Person sehen und bewerten? + * + * Fuer Creator gilt die gewohnte Betreuungsregel (darfCreator). Bei + * einem MODI reicht sie nicht: `darfCreator` sagt bei siehtAlles() + * pauschal ja -- und dazu gehoert auch Spicy Media. Die Modis gehen + * Spicy Media aber nichts an; das ist die Regel des ganzen Hauses, + * und sie darf nicht ausgerechnet hier eine Ausnahme haben. + */ +function darfPerson(person, zielId) { + const ziel = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(Number(zielId)); + if (!ziel) return false; + if (ziel.rolle === "modi") return siehtModis(person); + return darfCreator(person, ziel.id); } /* ---------- Die Liste ------------------------------------------------------ */ @@ -204,13 +267,29 @@ checklisteRouter.get("/workspace/api/checkliste/:bereich", (req, res) => { /* Wen darf die Person auswaehlen? */ let creatoren = []; - if (req.person.rolle !== "creator") { + /* Ein Modi arbeitet -- wie ein Creator -- an seiner EIGENEN Liste. + Ohne diese Zeile fiele er in den Zweig darunter, bekaeme eine + Auswahl fremder Creator vorgesetzt und seine eigene Liste gar + nicht zu sehen. */ + if (req.person.rolle !== "creator" && req.person.rolle !== "modi") { /* Nur DogFather waehlt aus allen Creatorn; ein Manager aus denen, die ihm zugeteilt sind (01.09.2026). */ const ids = siehtAlles(req.person) ? db().prepare("SELECT id FROM personen WHERE rolle = 'creator' AND aktiv = 1") .all().map((z) => z.id) : betreuteIds(req.person); + /* DIE MODIS KOMMEN DAZU -- aber nur fuer die DogFather-Rolle + (10.09.2026). Wunsch Filipe: "dass vanvan und ich diese sachen + von den modis auch in der dogfather rolle sehen". + + AUSDRUECKLICH NICHT UEBER siehtAlles(): Darin steckt auch Spicy + Media, und die Modis gehen sie nichts an. Das ist die Regel des + ganzen Hauses; sie darf nicht ausgerechnet hier eine Ausnahme + haben, nur weil die Zeile daneben schon passte. */ + if (req.person.rolle === "admin") { + for (const z of db().prepare( + "SELECT id FROM personen WHERE rolle = 'modi' AND aktiv = 1").all()) ids.push(z.id); + } creatoren = ids.length ? db().prepare(`SELECT id, name FROM personen WHERE id IN (${ids.map(() => "?").join(",")}) ORDER BY name`).all(...ids) @@ -240,15 +319,34 @@ checklisteRouter.get("/workspace/api/checkliste/:bereich", (req, res) => { } } + /* WELCHER PUNKTESATZ? Der der Person, um die es geht -- nicht der + der Person, die zusieht. DogFather waehlt einen Modi aus und + bekommt dessen Liste, nicht die eines Creators. */ + const zielRolle = creatorId > 0 + ? db().prepare("SELECT rolle FROM personen WHERE id = ?").get(creatorId)?.rolle + : req.person.rolle; + const gruppen = gruppenFuer(bereich, zielRolle === "modi"); + res.json({ bereich, name: liste.name, - gruppen: liste.gruppen, + gruppen, stand, nachrichten, creatoren, creator_id: creatorId > 0 ? creatorId : null, + /* WIE DIE AUSWAHL HEISST. Fuer einen Manager oder Scout stehen + dort nur Creator -- "Creator" ist dann richtig. Bei der + DogFather-Rolle stehen seit dem 10.09.2026 auch Modis darin, + und ein Auswahlfeld namens "Creator", in dem ein Modi steht, + ist schlicht falsch beschriftet. Das Wort kommt vom Server: + Ein Rollenvergleich im Browser waere die Stelle, an der der + Rollenname in einer ausgelieferten Datei landet. */ + wahl_name: req.person.rolle === "admin" ? "Person" : "Creator", /* Die Oberflaeche soll nicht raten muessen, wer was darf -- sonst zeigt sie einem Creator einen Knopf, der beim Klicken 403 sagt. Das ist schlechter als kein Knopf. */ - darf_bewerten: req.person.rolle !== "creator", + /* Wer an der EIGENEN Liste arbeitet, bewertet sie nicht selbst -- + weder Creator noch Modi. Die Stufe setzt die Betreuung + beziehungsweise die DogFather-Rolle. */ + darf_bewerten: req.person.rolle !== "creator" && req.person.rolle !== "modi", ich: req.person.id, }); } catch (fehler) { @@ -264,7 +362,7 @@ checklisteRouter.put("/workspace/api/checkliste/:bereich/:schluessel", gleicheHe try { /* Ein Creator bewertet sich nicht selbst. Waere das moeglich, stuende alles auf gruen und die Ampel waere wertlos. */ - if (req.person.rolle === "creator") { + if (req.person.rolle === "creator" || req.person.rolle === "modi") { return res.status(403).json({ fehler: "Die Bewertung setzt deine Betreuung. Du kannst hier antworten.", }); diff --git a/server/workspace-modi-punkte.js b/server/workspace-modi-punkte.js new file mode 100644 index 00000000..f35d26c6 --- /dev/null +++ b/server/workspace-modi-punkte.js @@ -0,0 +1,280 @@ +/* ===================================================================== + DIE CHECKLISTEN-PUNKTE EINES MODIS (10.09.2026) + + Wunsch Filipe: *"diese kategorien mussen auf dieser seite auch noch + auf die modis perfektionniert werden, da mussen lauter sachen sein + die vanvan und ich danach verteilen können und da anpassen können ob + gut oder nicht."* + + WARUM EIN ZWEITER SATZ UND NICHT EIN ERWEITERTER: Die vorhandenen + Punkte (LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE) sind fuer + CREATOR geschrieben -- "Upload messen", "Sendeplan festlegen", + "Verweildauer vergleichen". Ein Modi sendet nicht, er moderiert. Ihm + dieselbe Liste vorzulegen hiesse, ihn an Dingen zu messen, die nicht + seine Arbeit sind. + + DIESELBEN DREI BEREICHE UND DIESELBEN GRUPPEN wie beim Creator -- + Vor/Waehrend/Nach, Moderation/Aktion/Konflikt, Setup/Problem/Loesung. + Das ist Absicht: Zwei verschiedene Einteilungen fuer dieselbe Sache + waeren zwei Dinge zum Lernen statt einem, und die Oberflaeche kaeme + ohne Umbau nicht damit zurecht. + + DIESELBEN VIER STUFEN (anfaenger, fort, profi, meister). Sie + entscheiden, was jemandem zuerst gezeigt wird. Ein Modi in der + Probezeit soll nicht an "Muster ueber mehrere Sendungen erkennen" + gemessen werden. + + DIE SCHLUESSEL BEGINNEN MIT "m-". Der Stand haengt in `punkt_stand` + an (bereich, schluessel, person) -- ein Modi und ein Creator im + selben Bereich duerfen sich also nicht in die Quere kommen. Mit dem + Praefix kann das nicht passieren, auch wenn jemand spaeter einen + Punkt gleich benennt. + + BEWERTET WIRD NICHT VON IHM SELBST, sondern von der DogFather-Rolle + -- offen / gut / verbessern, wie beim Creator auch. Genau das war + Filipes Wunsch: "verteilen ... und anpassen ob gut oder nicht". + ===================================================================== */ + +export const MODI_LIVE_PUNKTE = { + vorbereitung: [ + { schluessel: "m-schicht-bestaetigt", titel: "Schicht bestätigt", stufe: "anfaenger", + text: "Vor dem Tag gesagt, ob du kannst. Ein „ich schau mal“ ist keine Zusage – und das Team plant danach." }, + { schluessel: "m-ankuendigung-gesehen", titel: "Ankündigung gesehen", stufe: "anfaenger", + text: "Du weißt, was draußen versprochen wurde. Sonst beantwortest du im Chat Fragen zu etwas, das du nicht kennst." }, + { schluessel: "m-ablauf-gelesen", titel: "Ablaufplan gelesen", stufe: "anfaenger", + text: "Anfang, Mitte, Schluss – grob im Kopf. Wer den Ablauf kennt, kann den Chat darauf vorbereiten." }, + { schluessel: "m-regeln-griffbereit", titel: "Regeln griffbereit", stufe: "anfaenger", + text: "Der Link oder der Text, den du hineinstellst, ohne zu suchen. Im Ernstfall zählen Sekunden." }, + { schluessel: "m-eigene-technik", titel: "Eigene Technik geprüft", stufe: "anfaenger", + text: "Dein Gerät, deine Verbindung, dein Zugang. Ein Modi, der mittendrin herausfliegt, fehlt genau dann." }, + { schluessel: "m-wer-ist-dabei", titel: "Wer moderiert heute noch?", stufe: "fort", + text: "Zu zweit teilt man sich auf – wer begrüßt, wer greift ein. Allein macht man beides halb." }, + { schluessel: "m-ziel-bekannt", titel: "Ziel der Sendung bekannt", stufe: "fort", + text: "Eine Zahl oder eine Sache. Darauf kannst du im Chat hinweisen, ohne zu fragen." }, + { schluessel: "m-gaeste-bekannt", titel: "Gäste bekannt", stufe: "fort", + text: "Wer kommt, wann, und wie heißt er richtig. Ein falsch geschriebener Name im Chat bleibt hängen." }, + { schluessel: "m-wortfilter-durchgesehen", titel: "Wortfilter durchgesehen", stufe: "fort", + text: "Was letzte Woche durchrutschte, gehört heute hinein. Ein Filter, den niemand pflegt, veraltet." }, + { schluessel: "m-eskalationsweg", titel: "Eskalationsweg im Kopf", stufe: "fort", + text: "Ab wann gibst du weiter, statt selbst zu entscheiden – und an wen. Vorher klären, nicht im Moment." }, + { schluessel: "m-begruessung-bereit", titel: "Begrüßungssätze bereit", stufe: "profi", + text: "Drei verschiedene, nicht einer dreimal. Wer merkt, dass ein Baustein antwortet, antwortet nicht zurück." }, + { schluessel: "m-umfrage-vorbereitet", titel: "Umfrage vorbereitet", stufe: "profi", + text: "Höchstens drei Antworten, vorher aufgeschrieben. Mitten im Stream formuliert man keine gute Frage." }, + { schluessel: "m-vertretung-geklaert", titel: "Vertretung geklärt", stufe: "profi", + text: "Wer übernimmt, wenn du wegmusst. Ein Chat ohne Aufsicht ist schlimmer als einer, der zu ist." }, + { schluessel: "m-uebergabe-vorbereitet", titel: "Übergabe an die nächste Schicht", stufe: "meister", + text: "Was die andere Person wissen muss, bevor du gehst. Zwei Zeilen reichen – aber sie müssen da sein." }, + ], + + mitschrift: [ + { schluessel: "m-chat-im-blick", titel: "Chat wirklich im Blick", stufe: "anfaenger", + text: "Nicht nebenbei. Wer mitschaut statt mitliest, sieht den Verstoß erst, wenn zehn Leute geantwortet haben." }, + { schluessel: "m-neue-begruesst", titel: "Neue begrüßt", stufe: "anfaenger", + text: "Mit Namen, wenn es geht. Wer beim ersten Mal angesprochen wird, kommt deutlich häufiger wieder." }, + { schluessel: "m-spam-sofort-weg", titel: "Spam sofort weg", stufe: "anfaenger", + text: "Löschen, bevor jemand antwortet. Eine Diskussion über einen gelöschten Beitrag kostet mehr als der Beitrag." }, + { schluessel: "m-fragen-gesammelt", titel: "Fragen gesammelt", stufe: "anfaenger", + text: "An einer Stelle, nicht im Kopf. Was im Chat vorbeirauscht, ist nach zwei Minuten weg." }, + { schluessel: "m-auf-ziel-hingewiesen", titel: "Auf das Ziel hingewiesen", stufe: "fort", + text: "Lieber zweimal beiläufig als einmal ausführlich. Wer später dazukommt, hat es sonst nie gehört." }, + { schluessel: "m-interaktion-angestossen", titel: "Interaktion angestoßen", stufe: "fort", + text: "Eine Frage in den Chat, wenn es still wird. Stille im Chat sieht für Neue aus wie ein leerer Raum." }, + { schluessel: "m-clipmomente-markiert", titel: "Clip-Momente markiert", stufe: "fort", + text: "Mit Uhrzeit, sofort. Am nächsten Tag findet niemand die Stelle wieder, an der alle gelacht haben." }, + { schluessel: "m-zeit-im-blick", titel: "Zeit im Blick behalten", stufe: "fort", + text: "Ein kurzes Zeichen genügt. Wer sendet, hat kein Gefühl für die Uhr – das ist normal." }, + { schluessel: "m-verwarnen-vor-sperren", titel: "Erst verwarnen, dann sperren", stufe: "profi", + text: "Außer bei den Sachen, die sofort gehen müssen. Eine Verwarnung, die jeder sieht, wirkt auf mehr als einen." }, + { schluessel: "m-stammzuschauer-angesprochen", titel: "Stammzuschauer angesprochen", stufe: "profi", + text: "Wiedererkannt zu werden ist der halbe Grund wiederzukommen." }, + { schluessel: "m-eskalation-weitergegeben", titel: "Eskalation weitergegeben", stufe: "profi", + text: "Nach der Regel, nicht nach Gefühl. Allein zu entscheiden, wo die Regel etwas anderes sagt, bringt dich in die Schusslinie." }, + { schluessel: "m-nichts-oeffentlich-ausdiskutiert", titel: "Nichts öffentlich ausdiskutiert", stufe: "profi", + text: "Im Chat gewinnt niemand. Kurz sagen, was gilt, und weiter – der Rest gehört ins Team." }, + { schluessel: "m-stimmung-gemeldet", titel: "Stimmung gemeldet", stufe: "meister", + text: "Wenn es kippt, bevor es kippt. Du siehst es früher als jeder, der gerade sendet." }, + { schluessel: "m-ruhig-geblieben", titel: "Ruhig geblieben, als es eng wurde", stufe: "meister", + text: "Der Ton eines Modis wird zum Ton des Chats. Das ist der schwerste Punkt auf dieser Liste." }, + ], + + auswertung: [ + { schluessel: "m-auffaelligkeiten-notiert", titel: "Auffälligkeiten notiert", stufe: "anfaenger", + text: "Solange du es noch weißt. Am nächsten Tag fehlt die Hälfte, und zwar die interessante." }, + { schluessel: "m-clipmarken-weitergegeben", titel: "Clip-Marken weitergegeben", stufe: "anfaenger", + text: "An die, die schneiden. Eine Marke, die niemand bekommt, ist keine." }, + { schluessel: "m-feedback-im-teamchat", titel: "Kurzes Feedback im Team-Chat", stufe: "anfaenger", + text: "Eine Sache, die gut lief, eine, die man ändert. Wer sich fünf vornimmt, ändert keine." }, + { schluessel: "m-vorfaelle-festgehalten", titel: "Vorfälle festgehalten", stufe: "fort", + text: "Wer, wann, was – und was du getan hast. Beim zweiten Mal mit derselben Person zählt genau das." }, + { schluessel: "m-chatverlauf-gesichert", titel: "Chatverlauf gesichert", stufe: "fort", + text: "Vor dem Schließen. Er ist die einzige Quelle dafür, was wirklich gesagt wurde." }, + { schluessel: "m-offene-fragen-beantwortet", titel: "Offene Fragen nachgereicht", stufe: "fort", + text: "Was im Stream unterging, gehört danach beantwortet – sonst fragt niemand ein zweites Mal." }, + { schluessel: "m-eine-sache-fuers-naechste-mal", titel: "Eine Sache fürs nächste Mal", stufe: "fort", + text: "Aufgeschrieben, nicht vorgenommen. Ein Vorsatz ohne Zettel ist am Freitag weg." }, + { schluessel: "m-regel-vorgeschlagen", titel: "Regel vorgeschlagen, wo eine fehlte", stufe: "profi", + text: "Wenn du zweimal improvisieren musstest, fehlt eine Regel – nicht Erfahrung." }, + { schluessel: "m-wiederkehrende-stoerer", titel: "Wiederkehrende Störer notiert", stufe: "profi", + text: "Dieselben Namen tauchen wieder auf. Eine Liste macht aus drei Einzelfällen ein Muster." }, + { schluessel: "m-zahlen-angesehen", titel: "Zahlen angesehen", stufe: "profi", + text: "Wie viele kamen, wie lange blieben sie. Nicht um zu bewerten, sondern um zu wissen, wann der Chat kippte." }, + { schluessel: "m-uebergabe-geschrieben", titel: "Übergabe geschrieben", stufe: "meister", + text: "So, dass jemand anderes die nächste Sendung übernehmen könnte. Wer nicht ersetzbar ist, kann nicht krank werden." }, + { schluessel: "m-muster-ueber-sendungen", titel: "Muster über mehrere Sendungen erkannt", stufe: "meister", + text: "Immer dieselbe Uhrzeit, immer dieselbe Sorte Kommentar. Das sieht nur, wer mehrere Abende nebeneinanderlegt." }, + ], +}; + +export const MODI_COMMUNITY_PUNKTE = { + moderation: [ + { schluessel: "m-regeln-gelesen", titel: "Regeln gelesen und verstanden", stufe: "anfaenger", + text: "Nicht überflogen. Du wirst danach gefragt, und eine falsche Auskunft ist schwerer zurückzunehmen als keine." }, + { schluessel: "m-eigene-rechte-bekannt", titel: "Eigene Rechte bekannt", stufe: "anfaenger", + text: "Was du darfst: löschen, stummschalten, sperren – und was nicht. Wer es nicht weiß, tut zu viel oder zu wenig." }, + { schluessel: "m-tonfall", titel: "Tonfall gefunden", stufe: "anfaenger", + text: "Freundlich und kurz. Ein Modi, der sich rechtfertigt, verliert; einer, der herrisch wird, auch." }, + { schluessel: "m-haeufige-fragen", titel: "Häufige Fragen im Kopf", stufe: "anfaenger", + text: "Die fünf, die jeden Abend kommen. Wer sie kennt, muss nicht jedes Mal nachsehen." }, + { schluessel: "m-warnung-vor-sperre", titel: "Reihenfolge klar: Hinweis, Warnung, Sperre", stufe: "fort", + text: "Vorher entscheiden, nicht im Moment. Wer im Ärger sperrt, sperrt irgendwann falsch." }, + { schluessel: "m-minderjaehrige", titel: "Umgang mit Minderjährigen", stufe: "fort", + text: "Wie erkennt man sie, was gilt dann. Das ist keine Ermessensfrage." }, + { schluessel: "m-privat-vs-oeffentlich", titel: "Privat statt öffentlich", stufe: "fort", + text: "Was eine Person betrifft, klärt man mit ihr – nicht vor zweihundert Leuten." }, + { schluessel: "m-keine-versprechen", titel: "Keine Versprechen im Namen anderer", stufe: "fort", + text: "\"Das machen wir\" bindet das ganze Team. \"Ich gebe es weiter\" bindet nur dich." }, + { schluessel: "m-entscheidung-begruenden", titel: "Entscheidung in einem Satz begründen können", stufe: "profi", + text: "Nicht im Chat – aber wenn DogFather fragt. Eine Sperre, die niemand erklären kann, wird zurückgenommen." }, + { schluessel: "m-eigene-grenze", titel: "Eigene Grenze kennen", stufe: "profi", + text: "Wann du aussteigst und jemanden holst. Weiterzumachen, wenn es dich mitnimmt, hilft niemandem." }, + { schluessel: "m-gleich-behandeln", titel: "Alle gleich behandeln", stufe: "profi", + text: "Auch die, die man mag. Eine Ausnahme sehen mehr Leute, als man denkt." }, + { schluessel: "m-neue-modis-eingearbeitet", titel: "Neue Modis eingearbeitet", stufe: "meister", + text: "Einen Abend nebenher laufen lassen und danach besprechen. Zusehen lernt mehr als jede Regelliste." }, + ], + + aktion: [ + { schluessel: "m-willkommen-gepflegt", titel: "Willkommens-Routine gepflegt", stufe: "anfaenger", + text: "Ein Satz, der nicht nach Textbaustein klingt. Wer merkt, dass ein Automat schreibt, antwortet nicht." }, + { schluessel: "m-namen-gemerkt", titel: "Namen gemerkt", stufe: "anfaenger", + text: "Die zehn, die immer da sind. Mehr braucht es nicht, damit sich eine Community erkannt fühlt." }, + { schluessel: "m-fragen-in-den-stream", titel: "Fragen in den Stream gegeben", stufe: "anfaenger", + text: "Gesammelt, ausgewählt, weitergereicht. Nicht alles – die drei, die etwas bringen." }, + { schluessel: "m-umfrage-ausgewertet", titel: "Umfrage ausgewertet", stufe: "fort", + text: "Und das Ergebnis gesagt. Eine Abstimmung ohne Ergebnis fragt beim nächsten Mal niemand mehr mit." }, + { schluessel: "m-aktion-vorgeschlagen", titel: "Eine Aktion vorgeschlagen", stufe: "fort", + text: "Challenge, Q&A, gemeinsamer Abend. Du hörst im Chat, was ankommt, früher als alle anderen." }, + { schluessel: "m-stille-belebt", titel: "Stille belebt statt ausgesessen", stufe: "fort", + text: "Eine Frage, ein Bezug auf vorhin. Ein stiller Chat wirkt auf Neue wie ein leerer Raum." }, + { schluessel: "m-rueckmeldung-eingesammelt", titel: "Rückmeldungen eingesammelt", stufe: "fort", + text: "Was Zuschauer sich wünschen, gehört weitergegeben – auch wenn es nicht umgesetzt wird." }, + { schluessel: "m-stammzuschauer-liste", titel: "Liste der Stammzuschauer gepflegt", stufe: "profi", + text: "Wer seit Wochen da ist, verdient mehr als ein Herz. Ohne Liste vergisst man ihn." }, + { schluessel: "m-neue-in-die-runde", titel: "Neue in die Runde geholt", stufe: "profi", + text: "Sie einmal namentlich ins Gespräch ziehen. Der Unterschied zwischen Zuschauen und Dabeisein." }, + { schluessel: "m-eigene-idee-eingetragen", titel: "Eigene Idee ins Ideen-Board", stufe: "profi", + text: "Was dir im Chat auffällt, ist die beste Quelle für Inhalte – aber nur, wenn es aufgeschrieben wird." }, + { schluessel: "m-aktion-durchgezogen", titel: "Eine Aktion von Anfang bis Ende begleitet", stufe: "meister", + text: "Ankündigen, durchführen, hinterher sagen, was daraus wurde. Der letzte Teil wird fast immer vergessen." }, + { schluessel: "m-community-uebersetzt", titel: "Zwischen Community und Team übersetzt", stufe: "meister", + text: "Was im Chat gemeint ist, und was im Team ankommt, sind oft zwei Dinge. Diese Lücke zu schließen ist die eigentliche Arbeit." }, + ], + + konflikt: [ + { schluessel: "m-erst-lesen", titel: "Erst lesen, dann handeln", stufe: "anfaenger", + text: "Zwei Sätze zurückscrollen. Die Hälfte der Streitfälle sieht mit dem Satz davor anders aus." }, + { schluessel: "m-nicht-mitstreiten", titel: "Nicht mitstreiten", stufe: "anfaenger", + text: "Du bist nicht Partei. In dem Moment, in dem du eine wirst, hast du deine Rolle verloren." }, + { schluessel: "m-kurz-und-klar", titel: "Kurz und klar sagen, was gilt", stufe: "anfaenger", + text: "Ein Satz, keine Begründungskette. Je länger die Erklärung, desto mehr Angriffsfläche." }, + { schluessel: "m-abkuehlen-lassen", titel: "Abkühlen lassen statt eskalieren", stufe: "fort", + text: "Stummschalten auf Zeit ist oft besser als sperren. Man kann es zurücknehmen." }, + { schluessel: "m-zweite-meinung", titel: "Zweite Meinung geholt", stufe: "fort", + text: "Bei allem, was größer ist als ein blöder Kommentar. Zu zweit entscheidet es sich leichter und besser." }, + { schluessel: "m-belege-gesichert", titel: "Belege gesichert", stufe: "fort", + text: "Bildschirmfoto, bevor der Beitrag weg ist. Ohne Beleg steht später Aussage gegen Aussage." }, + { schluessel: "m-danach-gemeldet", titel: "Danach im Team gemeldet", stufe: "fort", + text: "Auch wenn es gut ausging. DogFather und die rechte Hand sollten es von dir erfahren, nicht von anderen." }, + { schluessel: "m-keine-drohungen", titel: "Keine Drohungen, nur Folgen", stufe: "profi", + text: "\"Noch einmal und du bist raus\" ist eine Ansage. Alles darüber hinaus ist Streit." }, + { schluessel: "m-nach-der-sperre", titel: "Nach der Sperre nicht nachtreten", stufe: "profi", + text: "Kein Kommentar hinterher, kein Witz darüber. Der Chat merkt sich das." }, + { schluessel: "m-eigene-fehler", titel: "Eigenen Fehler zugegeben", stufe: "profi", + text: "Falsch gesperrt? Zurücknehmen und einmal sagen. Das kostet weniger als der Versuch, recht zu behalten." }, + { schluessel: "m-ernstfall-geprobt", titel: "Ernstfall einmal durchgesprochen", stufe: "meister", + text: "Wer tut was, wenn es wirklich kippt. Im Ernstfall zum ersten Mal darüber nachzudenken ist zu spät." }, + { schluessel: "m-daraus-gelernt", titel: "Aus dem Fall eine Regel gemacht", stufe: "meister", + text: "Ein Vorfall, aus dem nichts folgt, kommt wieder. Der Ertrag ist die Regel, nicht die Sperre." }, + ], +}; + +export const MODI_TECHNIK_PUNKTE = { + setup: [ + { schluessel: "m-zugaenge-funktionieren", titel: "Zugänge funktionieren", stufe: "anfaenger", + text: "Einmal vor der Sendung anmelden, nicht währenddessen. Ein abgelaufener Zugang fällt sonst im schlechtesten Moment auf." }, + { schluessel: "m-benachrichtigungen-an", titel: "Benachrichtigungen an", stufe: "anfaenger", + text: "Für Aufgaben und Team-Nachrichten. Ein Hinweis, den niemand bekommt, ist keiner." }, + { schluessel: "m-zweites-geraet", titel: "Zweites Gerät griffbereit", stufe: "anfaenger", + text: "Handy neben dem Rechner. Wenn eines hängt, moderierst du mit dem anderen weiter." }, + { schluessel: "m-verbindung-stabil", titel: "Verbindung stabil", stufe: "anfaenger", + text: "Kabel schlägt WLAN. Ein Modi, der alle zehn Minuten neu lädt, sieht die Hälfte nicht." }, + { schluessel: "m-chatfenster-eingerichtet", titel: "Chatfenster richtig eingerichtet", stufe: "fort", + text: "Groß genug, ohne anderes darüber. Wer scrollen muss, um mitzulesen, liest nicht mit." }, + { schluessel: "m-werkzeuge-kennen", titel: "Moderations-Werkzeuge kennen", stufe: "fort", + text: "Wo Löschen, Stummschalten, Sperren sitzt – ohne zu suchen. Im Ernstfall zählen Sekunden." }, + { schluessel: "m-automod-verstanden", titel: "Automatische Filter verstanden", stufe: "fort", + text: "Was sie wegnehmen und was nicht. Wer sich darauf verlässt, ohne es zu wissen, übersieht genau die Lücke." }, + { schluessel: "m-app-installiert", titel: "Die App wirklich installiert", stufe: "fort", + text: "Nicht als Lesezeichen. Nur so kommen Erinnerungen an, auch wenn der Browser zu ist." }, + { schluessel: "m-tastenkuerzel", titel: "Tastenkürzel eingerichtet", stufe: "profi", + text: "Die drei, die du am häufigsten brauchst. Aus zwei Sekunden werden über einen Abend Minuten." }, + { schluessel: "m-ruhe-eingestellt", titel: "Ruhezeiten eingestellt", stufe: "profi", + text: "Damit die App nachts still ist. Wer um drei geweckt wird, schaltet sie irgendwann ganz ab." }, + { schluessel: "m-ersatzweg", titel: "Ersatzweg ins Netz", stufe: "meister", + text: "Mobilfunk, einmal wirklich benutzt. Ein Ersatz, den man erst im Ausfall einrichtet, ist keiner." }, + ], + + problem: [ + { schluessel: "m-was-genau-faellt-aus", titel: "Was genau fällt aus?", stufe: "anfaenger", + text: "Chat, Bild, dein Zugang oder das ganze Netz – das sind vier verschiedene Probleme mit vier Lösungen." }, + { schluessel: "m-sofort-gesagt", titel: "Sofort gesagt, dass etwas ist", stufe: "anfaenger", + text: "Lieber einmal zu viel im Team-Chat. Ein stiller Modi sieht aus wie ein anwesender." }, + { schluessel: "m-notiert-solange-frisch", titel: "Notiert, solange es frisch ist", stufe: "anfaenger", + text: "Uhrzeit, Fehlermeldung, was du zuletzt getan hast. Am nächsten Tag fehlt genau das." }, + { schluessel: "m-eine-aenderung", titel: "Nur eine Änderung auf einmal", stufe: "fort", + text: "Wer drei Dinge gleichzeitig ändert und es geht wieder, weiß nicht warum – und steht beim nächsten Mal am Anfang." }, + { schluessel: "m-uebergeben-statt-kaempfen", titel: "Übergeben statt kämpfen", stufe: "fort", + text: "Nach fünf Minuten Basteln moderiert jemand anderes. Der Chat wartet nicht auf deine Technik." }, + { schluessel: "m-bildschirmfoto", titel: "Bildschirmfoto gemacht", stufe: "fort", + text: "Eine Fehlermeldung, die man nachher beschreibt, ist eine andere als die, die dastand." }, + { schluessel: "m-wiederholbar", titel: "Geprüft, ob es wiederkommt", stufe: "profi", + text: "Einmal weg ist nicht gelöst. Beim nächsten Mal nachsehen gehört dazu." }, + { schluessel: "m-nicht-alleine-suchen", titel: "Nicht allein stundenlang suchen", stufe: "profi", + text: "Nach zwanzig Minuten jemanden dazuholen. Zu zweit findet man es in fünf." }, + ], + + loesung: [ + { schluessel: "m-was-geholfen-hat", titel: "Was geholfen hat", stufe: "anfaenger", + text: "Genau die eine Änderung – nicht alles, was probiert wurde. Sonst ist es beim nächsten Mal wieder Raten." }, + { schluessel: "m-fuer-alle-aufgeschrieben", titel: "Für alle aufgeschrieben", stufe: "anfaenger", + text: "In die Bibliothek, nicht in den Chat. Was im Chat steht, ist nach zwei Tagen unauffindbar." }, + { schluessel: "m-anleitung-verstaendlich", titel: "Anleitung verständlich", stufe: "fort", + text: "Jemanden gegenlesen lassen, der es nicht erlebt hat. Was du selbst geschrieben hast, verstehst du immer." }, + { schluessel: "m-dauerhaft-oder-pflaster", titel: "Dauerhaft oder Pflaster?", stufe: "fort", + text: "Beides ist in Ordnung – aber es gehört dabeigeschrieben, damit niemand darauf baut." }, + { schluessel: "m-vorbeugen-vorgeschlagen", titel: "Vorbeugen vorgeschlagen", stufe: "profi", + text: "Wenn es zweimal passiert ist, ist es kein Zufall mehr, sondern eine fehlende Einstellung." }, + { schluessel: "m-wissen-weitergegeben", titel: "Wissen weitergegeben", stufe: "meister", + text: "Einmal im Team erklärt, statt es dreimal allein zu lösen. Ein Modi, der als Einziger etwas kann, ist ein Engpass." }, + ], +}; + +/* Alle drei unter den Bereichsnamen, unter denen sie gebraucht werden -- + damit die Auswahl an einer Stelle steht und nicht in jeder Abfrage + neu zusammengesucht wird. */ +export const MODI_PUNKTE = { + live: MODI_LIVE_PUNKTE, + community: MODI_COMMUNITY_PUNKTE, + technik: MODI_TECHNIK_PUNKTE, +}; diff --git a/server/workspace-teamlage.js b/server/workspace-teamlage.js new file mode 100644 index 00000000..8fd894bc --- /dev/null +++ b/server/workspace-teamlage.js @@ -0,0 +1,127 @@ +/* ===================================================================== + DIE LAGE DES MODI-TEAMS -- an einer Stelle (10.09.2026) + + Wunsch Filipe: *"dan will ich auch dass vanvan und ich diese sachen + von den modis auch in der dogfather rolle sehen. da soll es eine + kategorie geben für uns beide nur sonst keinen wo wir dass alles + sehen und behandeln können. so wie mit dem ideen-board."* + + NUR DIE DOGFATHER-ROLLE. Das sind Filipe und VanVan -- sie tragen + beide `admin`. Fuer alle anderen gibt es diese Seite nicht: 404 wie + bei einer Adresse, die niemand kennt. Ein "kein Zugriff" waere die + Auskunft, dass es sie gibt. + + SEHEN UND BEHANDELN, in dieser Reihenfolge: Die Seite zaehlt nur -- + bewertet wird dort, wo die Punkte stehen. Jede Zahl ist deshalb ein + Verweis in den jeweiligen Bereich, mit der Person schon + vorausgewaehlt. Eine zweite Stelle zum Bewerten waere eine zweite + Stelle, an der es auseinanderlaeuft. + + UND SIE IST KEINE UEBERWACHUNG. Dieselbe Ueberlegung steht schon + ueber team.html: Eine Seite mit Zahlen ueber Kollegen wird als + Kontrolle gelesen, und dann arbeitet niemand mehr offen damit. + Deshalb zaehlt sie, was NOCH NICHT BESPROCHEN ist -- nicht, wer wie + fleissig war. "Offen" heisst hier: Darueber habt ihr noch nicht + geredet. Es ist eine Liste fuer euch, keine Note fuer ihn. + ===================================================================== */ +import express from "express"; +import { + db, sitzungLesen, istDogFather, heuteLokal, +} from "./workspace.js"; +import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; + +export const teamlageRouter = express.Router(); + +/* Wie viele Punkte es je Bereich ueberhaupt gibt. Einmal gerechnet -- + die Saetze aendern sich nicht zur Laufzeit. */ +const GESAMT = Object.fromEntries( + Object.entries(MODI_PUNKTE).map(([b, g]) => [b, Object.values(g).flat().length])); + +teamlageRouter.get("/workspace/api/teamlage", (req, res) => { + try { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + /* 404 und nicht 403 -- siehe Kopf. */ + if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const modis = db().prepare( + `SELECT id, name, bild, letzter_login FROM personen + WHERE rolle = 'modi' AND aktiv = 1 ORDER BY name`).all(); + + if (!modis.length) { + return res.json({ modis: [], bereiche: GESAMT, heute: heuteLokal() }); + } + const platz = modis.map(() => "?").join(","); + const nummern = modis.map((m) => m.id); + const heute = heuteLokal(); + + /* ALLES IN VIER ABFRAGEN, nicht vier je Person. Bei zehn Modis + waeren das vierzig -- den Fehler hat das Haus bei den Terminen + schon einmal gemacht und ihn dort ausdruecklich vermerkt. */ + const stand = new Map(); // person -> bereich -> {gut, verbessern} + for (const z of db().prepare( + `SELECT creator_id, bereich, stufe, COUNT(*) AS n FROM punkt_stand + WHERE creator_id IN (${platz}) GROUP BY creator_id, bereich, stufe`).all(...nummern)) { + if (!stand.has(z.creator_id)) stand.set(z.creator_id, {}); + const b = stand.get(z.creator_id); + b[z.bereich] = b[z.bereich] || { gut: 0, verbessern: 0 }; + if (z.stufe === "gut" || z.stufe === "verbessern") b[z.bereich][z.stufe] = z.n; + } + + const aufgaben = new Map(); + for (const z of db().prepare( + `SELECT verantwortlich_id AS p, + COUNT(*) AS offen, + SUM(CASE WHEN frist IS NOT NULL AND frist < ? THEN 1 ELSE 0 END) AS ueberfaellig + FROM aufgaben + WHERE verantwortlich_id IN (${platz}) + AND status NOT IN ('erledigt', 'abgebrochen') + GROUP BY verantwortlich_id`).all(heute, ...nummern)) { + aufgaben.set(z.p, { offen: z.offen, ueberfaellig: z.ueberfaellig || 0 }); + } + + const ideen = new Map(); + for (const z of db().prepare( + `SELECT erstellt_von AS p, COUNT(*) AS n FROM eintraege + WHERE bereich = 'ideen' AND erstellt_von IN (${platz}) GROUP BY erstellt_von`) + .all(...nummern)) { + ideen.set(z.p, z.n); + } + + /* Wie viele Rueckmeldungen an seinen Punkten haengen -- das ist + das Gespraech, nicht die Note. */ + const nachrichten = new Map(); + for (const z of db().prepare( + `SELECT creator_id AS p, COUNT(*) AS n FROM punkt_nachricht + WHERE creator_id IN (${platz}) GROUP BY creator_id`).all(...nummern)) { + nachrichten.set(z.p, z.n); + } + + res.json({ + heute, + bereiche: GESAMT, + modis: modis.map((m) => { + const s = stand.get(m.id) || {}; + const je = {}; + for (const [b, gesamt] of Object.entries(GESAMT)) { + const gut = s[b]?.gut || 0; + const verbessern = s[b]?.verbessern || 0; + je[b] = { gesamt, gut, verbessern, offen: Math.max(gesamt - gut - verbessern, 0) }; + } + return { + id: m.id, + name: m.name, + bild: m.bild ? `/workspace/api/steckbrief/bild/${m.bild}` : null, + letzter_login: m.letzter_login, + aufgaben: aufgaben.get(m.id) || { offen: 0, ueberfaellig: 0 }, + ideen: ideen.get(m.id) || 0, + nachrichten: nachrichten.get(m.id) || 0, + bereiche: je, + }; + }), + }); + } catch (fehler) { + console.error("[workspace] Team-Lage:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 0c9f2d7f..fd55f36c 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -460,6 +460,30 @@ export function markeFuer(person) { return person?.rolle === "modi" ? MODI_MARKE : null; } +/* ===================================================================== + KACHELN, DIE ZU EINER VORHANDENEN LISTE DAZUKOMMEN (10.09.2026) + + bereicheFuer() ersetzt die Liste im Browser ganz -- das passt fuer + eine Rolle, die dort gar nicht vorkommt. Fuer die DogFather-Rolle + passt es nicht: Sie hat ihre 23 Kacheln, und es soll EINE dazu. + + Sie steht hier und nicht in assets/js/bereiche.js, weil dort jeder + mitliest. "Team-Lage" allein verraet zwar nichts -- aber eine + Kachel, die nur bei einer einzigen Rolle erscheint, wirft die Frage + auf, fuer wen sie ist. Genau die soll niemand stellen. + ===================================================================== */ +const ZUSATZ_BEREICHE = { + admin: [{ + gruppe: "Rund um das Team", + name: "Team-Lage", unter: "Was noch nicht besprochen ist", + zeichen: "teamlage", ton: 22, ziel: "teamlage.html", szene: "halle", + }], +}; + +export function zusatzBereicheFuer(person) { + return ZUSATZ_BEREICHE[person?.rolle] || []; +} + /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar genug, um Rateversuche auszubremsen, und unauffällig für einen echten Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie @@ -2499,6 +2523,12 @@ const GESCHUETZT = { Sicherungen, die KI und der Zustand des Servers -- das ist Betrieb, nicht Betreuung. */ "/workspace/automation.html": ["admin"], + /* Die gemeinsame Lage des Teams -- nur die DogFather-Rolle, also + Filipe und VanVan (10.09.2026). Fuer alle anderen leitet die + Schranke auf die Startseite; die Schnittstelle dahinter antwortet + zusaetzlich mit 404. Zwei Schloesser, absichtlich: Faellt eines + weg, haelt das andere. */ + "/workspace/teamlage.html": ["admin"], }; /* Die einzige Seite unter /workspace, die offen sein MUSS -- man kann @@ -2802,6 +2832,9 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => { bereiche: bereicheFuer(angesehen || person), rolle_text: rollentextFuer(angesehen || person), marke: markeFuer(angesehen || person), + /* Kacheln, die zur eigenen Liste DAZUkommen -- im Gegensatz zu + `bereiche`, das sie ersetzt. Leer fuer alle, die keine haben. */ + bereiche_zusatz: zusatzBereicheFuer(angesehen || person), }); }); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index d7b0d009..b4d3e323 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609100240", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609100303", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609100240", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609100303", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100240", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100303", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100240", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100303", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/js/checkliste.js b/workspace/assets/js/checkliste.js index e9546b3d..7bedb51a 100644 --- a/workspace/assets/js/checkliste.js +++ b/workspace/assets/js/checkliste.js @@ -83,6 +83,20 @@ Klappzustände: Es soll beim Neuzeichnen nicht zurückspringen. */ const stufenFilter = {}; + /* EIN VERWEIS KANN JEMANDEN VORAUSWAEHLEN (10.09.2026). + + Ohne das fuehrt ein Link aus einer Uebersicht zwar auf die + richtige Seite, aber auf die erstbeste Person darin -- man muesste + danach noch einmal auswaehlen, und beim zweiten Mal vergisst man, + warum man hier war. Gelesen wird es EINMAL; danach entscheidet die + Auswahl im Feld, sonst spraenge sie bei jedem Neuzeichnen zurueck. */ + try { + const ausAdresse = Number(new URLSearchParams(location.search).get('creator_id')); + if (Number.isInteger(ausAdresse) && ausAdresse > 0) { + for (const b of ['live', 'community', 'technik', 'content']) gewaehlt[b] = ausAdresse; + } + } catch { /* ohne Adresse bleibt es wie bisher */ } + window.checkliste = async function (behaelter, bereich) { if (!behaelter) return; let daten; @@ -120,7 +134,7 @@ if (daten.darf_bewerten && daten.creatoren.length) { const feld = el('div', 'liste-fest__wahl'); - feld.append(el('label', 'feld-schild', 'Creator')); + feld.append(el('label', 'feld-schild', daten.wahl_name || 'Creator')); const s = el('select'); for (const c of daten.creatoren) s.append(new Option(c.name, String(c.id))); if (daten.creator_id) s.value = String(daten.creator_id); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index dd242b25..89767ad3 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -171,9 +171,29 @@ } return [...nach.values()]; } - return GRUPPEN + /* KACHELN, DIE DAZUKOMMEN (10.09.2026). + + Anders als `ich.bereiche` (das die Liste ERSETZT) haengen + diese sich an eine vorhandene Gruppe an. Gebraucht wird das + fuer Kacheln, die nur eine einzige Rolle bekommt: In dieser + Datei duerften sie nicht stehen -- sie wird an jeden + ausgeliefert, und eine Kachel, die nur bei einem erscheint, + wirft die Frage auf, fuer wen sie ist. + + Findet sich die genannte Gruppe nicht, wird eine eigene + angelegt. Sonst faellt die Kachel lautlos weg, sobald jemand + eine Gruppe umbenennt -- und niemand merkt es, weil nichts + kaputtgeht. */ + const zusatz = Array.isArray(ich?.bereiche_zusatz) ? ich.bereiche_zusatz : []; + const gruppen = GRUPPEN .map((g) => ({ ...g, bereiche: g.bereiche.filter((b) => b.rollen.includes(wer.rolle)) })) .filter((g) => g.bereiche.length); + for (const b of zusatz) { + const g = gruppen.find((x) => x.name === b.gruppe); + if (g) g.bereiche = [...g.bereiche, b]; + else gruppen.push({ name: b.gruppe, unter: b.gruppeUnter || '', bereiche: [b] }); + } + return gruppen; } let laufNr = 0; diff --git a/workspace/assets/js/teamlage.js b/workspace/assets/js/teamlage.js new file mode 100644 index 00000000..b7a5c755 --- /dev/null +++ b/workspace/assets/js/teamlage.js @@ -0,0 +1,153 @@ +/* ===================================================================== + DIE LAGE DES TEAMS -- eine Seite fuer die, die sie sehen duerfen. + + Wunsch Filipe (10.09.2026): eine Stelle, an der er und VanVan alles + sehen und behandeln koennen. + + SIE ZAEHLT NUR, SIE BEWERTET NICHT. Bewertet wird dort, wo die + Punkte stehen -- je Bereich fuehrt ein Verweis dorthin, mit der + Person schon vorausgewaehlt. Eine zweite Stelle zum Bewerten waere + eine zweite Stelle, an der es auseinanderlaeuft. + + KEINE NEUEN CSS-KLASSEN. Fuer genau diese Art Karte gibt es sie + schon (`tperson`, `tz`, `schritt` -- benutzt von team.html). Elf + neue haetten in mehreren Stilvorlagen gepflegt werden muessen, fuer + ein Aussehen, das es bereits gibt. Zwei Bauarten fuer dieselbe Sache + sind ausserdem zwei Dinge zum Lernen statt einem. + + IN DIESER DATEI STEHT KEIN ROLLENNAME. Sie wird wie jede Datei unter + /workspace an JEDEN ausgeliefert, der die Seite oeffnet. Wer sie + bekommt, entscheiden der Server (404 fuer alle anderen) und die + Kachel, die ebenfalls vom Server kommt. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + const melde = (t) => { const f = $('fehler'); if (f) f.textContent = t || ''; }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('nicht erlaubt'); } + return a; + } + + const tageHer = (iso) => { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return Math.floor((Date.now() - d.getTime()) / 86400000); + }; + + /* Die drei Bereiche in derselben Reihenfolge wie auf der Startseite. + Die Namen stehen hier, weil die Seite sonst eine zweite Abfrage + braeuchte, nur um drei Woerter zu holen. */ + const BEREICHE = [ + { schluessel: 'live', name: 'Live-Ablauf' }, + { schluessel: 'community', name: 'Community' }, + { schluessel: 'technik', name: 'Technik' }, + ]; + + function zahl(wert, schild) { + const k = el('div', 'tz'); + k.append(el('strong', 'tz__wert', String(wert))); + k.append(el('span', 'tz__schild', schild)); + return k; + } + + function karte(m) { + const k = el('article', 'tperson'); + + const kopf = el('div', 'tperson__kopf'); + const zeichen = el('span', 'tperson__zeichen'); + if (m.bild) { + const b = document.createElement('img'); + b.src = m.bild; b.alt = ''; b.loading = 'lazy'; + zeichen.append(b); + } else { + zeichen.textContent = (m.name || '?').trim().charAt(0).toUpperCase(); + } + kopf.append(zeichen); + + const text = el('div', 'tperson__text'); + text.append(el('p', 'tperson__namen', m.name)); + /* WANN ZULETZT DA -- als Tatsache, nicht als Vorwurf. Wer zwei + Wochen nicht da war, hat vielleicht Urlaub; die Zahl sagt nur, + dass man nachfragen koennte. */ + const her = tageHer(m.letzter_login); + text.append(el('span', 'tperson__zuletzt', her === null + ? 'war noch nie angemeldet' + : her === 0 ? 'heute da gewesen' + : her === 1 ? 'gestern da gewesen' : `vor ${her} Tagen da gewesen`)); + kopf.append(text); + k.append(kopf); + + const zahlen = el('div', 'tperson__zahlen'); + zahlen.append(zahl(m.aufgaben.offen, 'Aufgaben offen')); + zahlen.append(zahl(m.aufgaben.ueberfaellig, 'überfällig')); + zahlen.append(zahl(m.ideen, 'Ideen')); + zahlen.append(zahl(m.nachrichten, 'Rückmeldungen')); + for (const b of BEREICHE) { + const z = m.bereiche[b.schluessel]; + if (z) zahlen.append(zahl(z.offen, `${b.name}: offen`)); + } + k.append(zahlen); + + /* UND HIER WIRD BEHANDELT. Ein Verweis je Bereich, der die Person + mitnimmt -- ohne sie muesste man dort noch einmal auswaehlen, + und beim zweiten Mal weiss man nicht mehr, warum man hier war. */ + const wege = el('div', 'tpipe__reihe'); + for (const b of BEREICHE) { + const z = m.bereiche[b.schluessel]; + if (!z) continue; + const a = el('a', 'schritt', + `${b.name} ansehen (${z.gut} gut · ${z.verbessern} verbessern)`); + a.href = `bereich.html?b=${b.schluessel}&creator_id=${m.id}`; + wege.append(a); + } + k.append(wege); + return k; + } + + async function laden() { + const ziel = $('lage'); + try { + const a = await hole('/workspace/api/teamlage'); + if (!a.ok) { melde('Die Lage konnte nicht geladen werden.'); return; } + const daten = await a.json(); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + const leute = daten.modis || []; + const offenGesamt = leute.reduce((s, m) => + s + Object.values(m.bereiche).reduce((x, b) => x + b.offen, 0), 0); + /* Eine leere Seite ohne Erklaerung sieht aus wie ein Fehler -- + deshalb steht hier auch, was zu tun waere. */ + $('kopf-unter').textContent = leute.length + ? `${leute.length} ${leute.length === 1 ? 'Person' : 'Personen'} · ` + + `${offenGesamt} Punkte noch nicht besprochen` + : 'Noch niemand im Team. Zugänge legst du unter „Personen & Zugänge“ an.'; + + for (const m of leute) ziel.append(karte(m)); + } catch { /* umgeleitet */ } + } + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + window.werZeigen?.(await a.json()); + } catch { return; } + $('abmelden')?.addEventListener('click', async () => { + try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' }); } + catch { /* egal */ } + location.assign('/workspace/'); + }); + await laden(); + })(); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 96af7809..9ba3bc2b 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,12 +12,12 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + +
@@ -245,10 +245,10 @@ - - - - - + + + + +