diff --git a/server/pruef-aufgaben-vorlagen.mjs b/server/pruef-aufgaben-vorlagen.mjs new file mode 100644 index 00000000..8c4b230f --- /dev/null +++ b/server/pruef-aufgaben-vorlagen.mjs @@ -0,0 +1,277 @@ +/* Prueft die Aufgaben-Vorlagen: vier Bereiche, vier Stufen, und den + Weg von der Vorlage zur echten Aufgabe. + + WARUM DAS EINE EIGENE PRUEFUNG IST. Die Vorlagen sind Daten -- dass + sie vollstaendig sind, laesst sich in Sekunden zaehlen. Interessant + ist der Weg DANACH: Aus einer Vorlage muss eine Zeile in `aufgaben` + werden, mit Creator, Frist und Status, und sie muss in der + Aufgabenliste des Creators wirklich auftauchen. Zwischen "die Vorlage + gibt es" und "der Creator sieht die Aufgabe" liegen vier Schichten, + und jede kann still danebengehen. + + DREI SORTEN GEGENPROBE, alle drei noetig: + - eine unbekannte Stufe MUSS abgelehnt werden, + - eine Uebernahme ohne Creator MUSS abgelehnt werden (eine Aufgabe + ohne Adressaten ist ein Zettel), + - ein Betreuer darf sie NICHT fuer einen fremden Creator anlegen. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-aufv-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4211"; +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(240_000, "pruef-aufgaben-vorlagen"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4211"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); +d.close(); + +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 kScout = await keks("scout", "CODE-SCOU-0001"); +const kTili = await keks("creator", "CODE-ANNA-0001"); +const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); +const senden = (pfad, k, art, koerper) => ruf(pfad, k, { + method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); + +/* ---------- 1. Die Vorlagen sind vollstaendig ---------------------------- */ +console.log("\n=== Vier Bereiche, vier Stufen, drei Aufgaben"); +const alles = await (await ruf("/workspace/api/vorlagen", kScout)).json(); +const V = alles.aufgaben; +ok(!!V, "die Aufgabenvorlagen werden ausgeliefert"); +const BEREICHE = ["live", "content", "technik", "community"]; +const STUFEN = ["anfaenger", "fort", "profi", "meister"]; +let gesamt = 0; +for (const b of BEREICHE) { + const fehlend = STUFEN.filter((st) => !(V[b] || {})[st] || !V[b][st].length); + const n = STUFEN.reduce((s, st) => s + ((V[b] || {})[st] || []).length, 0); + gesamt += n; + ok(fehlend.length === 0 && n >= 8, + `${b}: ${n} Aufgaben über ${STUFEN.length - fehlend.length} von 4 Stufen` + + (fehlend.length ? ` — fehlt: ${fehlend.join(", ")}` : "")); +} +ok(gesamt === 48, `zusammen ${gesamt} Aufgaben (erwartet 48)`); + +/* Jede Aufgabe braucht Titel, Text, Frist und einen eindeutigen + Schluessel -- eine Aufgabe ohne Frist ist ein Wunsch. */ +{ + const flach = BEREICHE.flatMap((b) => STUFEN.flatMap((st) => (V[b][st] || []))); + const unvollstaendig = flach.filter((v) => !v.titel || !v.text || !v.tage || !v.schluessel); + ok(unvollstaendig.length === 0, + `jede Aufgabe hat Titel, Text, Frist und Schlüssel (${flach.length - unvollstaendig.length} von ${flach.length})`); + const schluessel = flach.map((v) => v.schluessel); + const doppelt = schluessel.filter((x, i) => schluessel.indexOf(x) !== i); + ok(doppelt.length === 0, `alle Schlüssel eindeutig${doppelt.length ? ": " + doppelt.join(", ") : ""}`); + /* DIE FRIST WAECHST NICHT DURCHGEHEND MIT DER STUFE -- und das ist + kein Mangel. + + Der erste Anlauf verlangte anfaenger < fort < profi < meister und + schlug fehl: Profi kam auf 6,7 Tage, Fortgeschritten auf 7,4. Beim + Nachsehen war die Erwartung falsch, nicht die Zahl. Eine + Profi-Aufgabe ist meist eine MESSUNG -- "Upload messen und Bitrate + setzen", "Lautheit auf minus 20 bis minus 24 bringen": anspruchsvoll, + aber an einem Abend erledigt. Eine Fortgeschrittenen-Aufgabe wie + "Content-Saeulen anlegen und drei Wochen halten" braucht + zwangslaeufig Wochen, weil sie aus Wiederholung besteht. + + Die Stufe sagt, was man KOENNEN muss, nicht wie lange es dauert. + + Was dagegen wirklich gelten muss: Eine Anfaengeraufgabe ist an einem + Abend zu schaffen (sonst faengt niemand an), und eine Meisteraufgabe + ist es nie (sonst ist sie keine). */ + const schnitt = (st) => { + const l = BEREICHE.flatMap((b) => V[b][st] || []); + return l.reduce((s, v) => s + v.tage, 0) / l.length; + }; + const [sa, sf, sp, sm] = STUFEN.map(schnitt); + const anfListe = BEREICHE.flatMap((b) => V[b].anfaenger || []); + const meiListe = BEREICHE.flatMap((b) => V[b].meister || []); + const anfZuLang = anfListe.filter((v) => v.tage > 5); + const meiZuKurz = meiListe.filter((v) => v.tage < 7); + ok(anfZuLang.length === 0, + `jede Anfängeraufgabe ist in höchstens fünf Tagen zu schaffen` + + (anfZuLang.length ? ` — zu lang: ${anfZuLang.map((v) => v.schluessel).join(", ")}` : "")); + ok(meiZuKurz.length === 0, + `keine Meisteraufgabe unter einer Woche` + + (meiZuKurz.length ? ` — zu kurz: ${meiZuKurz.map((v) => v.schluessel).join(", ")}` : "")); + ok(sa < sm, + `Anfänger deutlich kürzer als Meister (${sa.toFixed(1)} gegen ${sm.toFixed(1)} Tage;` + + ` fort ${sf.toFixed(1)}, profi ${sp.toFixed(1)} — dazwischen gilt keine Reihenfolge)`); + /* GEGENPROBE: Eine erfundene Anfaengeraufgabe mit 30 Tagen MUSS + auffallen -- sonst prueft die Regel oben nichts. */ + ok([...anfListe, { schluessel: "test", tage: 30 }].filter((v) => v.tage > 5).length === 1, + `Gegenprobe: eine Anfängeraufgabe mit 30 Tagen fällt auf`); +} + +/* ---------- 2. Aus der Vorlage wird eine echte Aufgabe -------------------- */ +console.log("\n=== Von der Vorlage zur Aufgabe"); +{ + const vorher = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben?.length ?? 0; + const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0, creator_id: idTili }); + ok(a.ok, `der Scout kann eine Aufgabe für seinen Creator übernehmen (${a.status})`); + + const liste = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []; + ok(liste.length === vorher + 1, `sie steht in der Liste des Creators (${vorher} → ${liste.length})`); + const neu = liste.find((x) => x.titel === V.technik.anfaenger[0].titel); + ok(!!neu, `mit dem richtigen Titel ("${V.technik.anfaenger[0].titel}")`); + ok(!!neu && neu.status === "offen", `als offen angelegt (${neu?.status})`); + ok(!!neu && !!neu.frist, `und mit einer Frist (${neu?.frist})`); + /* Die Frist muss in der Zukunft liegen -- und zwar nach ORTSZEIT + gerechnet, nicht nach UTC. */ + if (neu?.frist) { + const heute = new Date(); + const h = `${heute.getFullYear()}-${String(heute.getMonth() + 1).padStart(2, "0")}` + + `-${String(heute.getDate()).padStart(2, "0")}`; + ok(neu.frist > h, `die Frist liegt in der Zukunft (heute ${h}, Frist ${neu.frist})`); + } +} + +/* Alle drei einer Stufe auf einmal. */ +{ + const vorher = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []).length; + const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgaben_alle", bereich: "community", stufe: "meister", creator_id: idTili }); + const nachher = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []).length; + ok(a.ok && nachher === vorher + 3, + `alle drei einer Stufe auf einmal (${vorher} → ${nachher})`); +} + +/* ---------- 3. Gegenproben ------------------------------------------------ */ +console.log("\n=== Gegenproben"); +{ + const a = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "technik", stufe: "gibt-es-nicht", nr: 0, creator_id: idTili }); + ok(a.status === 400, `eine unbekannte Stufe wird abgelehnt (${a.status})`); + + const b = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "erfunden", stufe: "anfaenger", nr: 0, creator_id: idTili }); + ok(b.status === 400, `ein unbekannter Bereich wird abgelehnt (${b.status})`); + + const c = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 99, creator_id: idTili }); + ok(c.status === 400, `eine Nummer, die es nicht gibt, wird abgelehnt (${c.status})`); + + const e = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0 }); + ok(e.status === 400, `ohne Creator wird abgelehnt — eine Aufgabe ohne Adressaten ist ein Zettel (${e.status})`); + + const f = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST", + { art: "aufgabe", bereich: "technik", stufe: "anfaenger", nr: 0, creator_id: idFremd }); + ok(f.status === 404, `für einen fremden Creator wird abgelehnt (${f.status})`); + + /* Und der Creator selbst legt sie fuer sich an -- ohne creator_id, + der Server setzt sie. Sonst koennte er sie jemand anderem geben. */ + const g = await senden("/workspace/api/vorlagen/uebernehmen", kTili, "POST", + { art: "aufgabe", bereich: "live", stufe: "anfaenger", nr: 0, creator_id: idFremd }); + const beiFremd = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || []) + .filter((x) => x.titel === V.live.anfaenger[0].titel).length; + ok(g.ok && beiFremd === 1, + `ein Creator legt sie für SICH an, auch wenn er jemand anderen angibt (${g.status})`); +} + +/* ---------- 4. Im Browser: sieht man es, und geht es? -------------------- */ +console.log("\n=== Auf der Aufgabenseite"); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1200, height: 1400 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("pageerror", (f) => konsole.push(f.message)); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="creator"]'); + await seite.fill("#code", "CODE-ANNA-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "domcontentloaded" }); + await seite.waitForTimeout(2500); + + const d = await seite.evaluate(() => { + const b = document.getElementById("vorlagenbrett"); + return { + sichtbar: !!b && !b.hidden, + reihen: document.querySelectorAll("#vorlagenbrett .stufenleiter").length, + karten: document.querySelectorAll("#vorlagenbrett .vorlagenbrett__karte").length, + knoepfe: document.querySelectorAll("#vorlagenbrett .vorlagenbrett__fuss .knopf").length, + /* Gestaltet oder nackt? Eine Karte ohne Kante ist keine Karte -- + derselbe Fehler wie damals bei der Checkliste, wo das CSS in + der falschen Datei lag und die Pruefung trotzdem gruen war, + weil sie nur Elemente gezaehlt hat. */ + gestaltet: (() => { + const k = document.querySelector("#vorlagenbrett .vorlagenbrett__karte"); + if (!k) return null; + const st = getComputedStyle(k); + return parseFloat(st.borderTopWidth) >= 1 && parseFloat(st.paddingLeft) >= 8; + })(), + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + ok(d.sichtbar, "der Block ist da"); + ok(d.reihen === 2, `zwei Knopfreihen: Bereich und Stufe (${d.reihen})`); + ok(d.karten === 3, `drei Aufgaben je Feld (${d.karten})`); + ok(d.knoepfe === 3, `jede hat einen Übernehmen-Knopf (${d.knoepfe})`); + ok(d.gestaltet === true, "die Karten sind gestaltet, nicht nackt"); + ok(d.ueber <= 0, `nichts steht seitlich über (${d.ueber}px)`); + + /* Der Bereich laesst sich wechseln, und die Karten wechseln mit. */ + const vorher = await seite.textContent("#vorlagenbrett .vorlagenbrett__titel"); + await seite.click("#vorlagenbrett .stufenleiter >> nth=0 >> .stufenleiter__knopf >> nth=2"); + await seite.waitForTimeout(500); + const nachher = await seite.textContent("#vorlagenbrett .vorlagenbrett__titel"); + ok(vorher !== nachher, `ein anderer Bereich zeigt andere Aufgaben ("${vorher}" → "${nachher}")`); + + /* Und der Weg bis zum Brett. */ + const zaehle = () => seite.evaluate(() => document.querySelectorAll("#brett .karte").length); + const brettVorher = await zaehle(); + await seite.click("#vorlagenbrett .vorlagenbrett__fuss .knopf"); + await seite.waitForTimeout(2500); + const brettNachher = await zaehle(); + ok(brettNachher === brettVorher + 1, + `Übernehmen legt eine Aufgabe auf dem Brett an (${brettVorher} → ${brettNachher})`); + ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? ": " + konsole[0] : ""}`); + await browser.close(); +} + +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-vorlagen.js b/server/workspace-vorlagen.js index 2dd7ce3e..4f8a9b69 100644 --- a/server/workspace-vorlagen.js +++ b/server/workspace-vorlagen.js @@ -250,10 +250,11 @@ export const LIVE_PUNKTE = { { schluessel: "die-ersten-dreissig-sekunden-stehen", titel: "Die ersten dreissig Sekunden stehen", text: "Wortwoertlich vorbereitet. In dieser halben Minute entscheidet sich, ob jemand bleibt, und genau dort improvisiert man am schlechtesten.", stufe: "profi" }, { schluessel: "ausweichweg-laeuft-mit", titel: "Ausweichweg laeuft mit", text: "Zweites Geraet bereit und angemeldet, nicht nur daneben. Ein Ersatz, den man erst einrichten muss, ist im Moment des Ausfalls keiner.", stufe: "meister" }, { schluessel: "ablauf-als-vorlage-nicht-als-zettel", titel: "Ablauf als Vorlage, nicht als Zettel", text: "Derselbe Aufbau jedes Mal, als Vorlage gespeichert. Was jedes Mal neu erfunden wird, wird jedes Mal anders gut.", stufe: "meister" }, - { schluessel: "ein-ziel-in-einer-zahl", titel: "Ein Ziel in einer Zahl", text: "Vorher festlegen, woran diese Sendung gemessen wird -- Verweildauer, Wiederkehrer, Kommentare. Ohne Zahl vorher ist die Auswertung danach eine Meinung.", stufe: "meister" }, /* --- Aus der Recherche vom 09.09.2026 --- */ + { schluessel: "ein-ziel-in-einer-zahl", titel: "Ein Ziel in einer Zahl", text: "Vorher festlegen, woran diese Sendung gemessen wird -- Verweildauer, Wiederkehrer, Kommentare. Ohne Zahl vorher ist die Auswertung danach eine Meinung.", stufe: "meister" }, + + /* --- Aus der Recherche vom 09.09.2026 --- */ { schluessel: "ankuendigung-kurz-vorher", titel: "Ankündigung kurz vorher", text: "Ein kurzes Video 30 bis 60 Minuten vor dem Start. Es bündelt die, die ohnehin kommen wollten – und ein voller Start zieht neue nach.", stufe: "fort" }, { schluessel: "sendeplan-steht-fest", titel: "Sendeplan steht fest", text: "Zwei feste Tage schlagen fünf zufällige. Wer weiß, wann du sendest, kommt wieder – und Wiederkehr ist das Signal, das man nicht fälschen kann.", stufe: "fort" }, - ], mitschrift: [ { schluessel: "guter-moment-aufheben", titel: "Guter Moment – aufheben", text: "Etwas ist gut gelaufen. Kurz notieren, woran es lag – daraus wird später ein Clip.", stufe: "anfaenger" }, @@ -264,9 +265,9 @@ export const LIVE_PUNKTE = { { schluessel: "neue-zuschauer-aufgetaucht", titel: "Neue Zuschauer aufgetaucht", text: "Woher kamen sie? Wurde jemand begrüßt oder ging es unter?", stufe: "fort" }, { schluessel: "ich-wusste-nicht-weiter", titel: "Ich wusste nicht weiter", text: "Stelle merken, an der es gestockt hat. Dafür gibt es Hilfe.", stufe: "anfaenger" }, { schluessel: "zeitstempel-statt-gefuehl", titel: "Zeitstempel statt Gefuehl", text: "Die Minute notieren, nicht 'irgendwann in der Mitte'. Nur mit der Minute findet man die Stelle in der Aufzeichnung wieder.", stufe: "profi" }, - { schluessel: "chatverlauf-gesichert", titel: "Chatverlauf gesichert", text: "Vor dem Schliessen speichern. Er ist die einzige Quelle dafuer, was wirklich gesagt wurde -- und bei einem Vorfall die einzige, die zaehlt.", stufe: "profi" }, { schluessel: "namen-vorgelesen", titel: "Namen vorgelesen", text: "Neue in den ersten Minuten begrüßen, nicht erst wenn Zeit ist. Wer beim Namen genannt wird, bleibt spürbar länger.", stufe: "anfaenger" }, + { schluessel: "chatverlauf-gesichert", titel: "Chatverlauf gesichert", text: "Vor dem Schliessen speichern. Er ist die einzige Quelle dafuer, was wirklich gesagt wurde -- und bei einem Vorfall die einzige, die zaehlt.", stufe: "profi" }, + { schluessel: "namen-vorgelesen", titel: "Namen vorgelesen", text: "Neue in den ersten Minuten begrüßen, nicht erst wenn Zeit ist. Wer beim Namen genannt wird, bleibt spürbar länger.", stufe: "anfaenger" }, { schluessel: "interaktion-eingefordert", titel: "Interaktion eingefordert", text: "Frage, Umfrage, Abstimmung. Ein Kommentar zählt mehr als stilles Zuschauen – und zieht den Stream zu neuen Leuten.", stufe: "fort" }, - ], auswertung: [ { schluessel: "was-hat-diesmal-getragen", titel: "Was hat diesmal getragen?", text: "Ein Ding. Nicht drei. Das eine, das man wiederholen will.", stufe: "anfaenger" }, @@ -277,10 +278,10 @@ export const LIVE_PUNKTE = { { schluessel: "braucht-besprechung", titel: "Braucht Besprechung", text: "Etwas, das man nicht allein lösen kann – geht an die Betreuung.", stufe: "anfaenger" }, { schluessel: "verweildauer-gegen-die-letzten-fuenf", titel: "Verweildauer gegen die letzten fuenf", text: "Nicht gegen das letzte Mal -- ein einzelner Vergleich ist Zufall. Fuenf Sendungen zeigen eine Richtung.", stufe: "profi" }, { schluessel: "ein-versuch-eine-aenderung", titel: "Ein Versuch, eine Aenderung", text: "Wer Titel, Uhrzeit und Aufbau zugleich aendert, weiss hinterher nicht, was gewirkt hat. Eine Sache je Sendung.", stufe: "profi" }, - { schluessel: "erkenntnis-zurueck-in-die-vorlage", titel: "Erkenntnis zurueck in die Vorlage", text: "Was sich bewaehrt hat, wird Teil des Ablaufs -- sonst muss man es sich jedes Mal neu merken.", stufe: "meister" }, { schluessel: "verweildauer-abgelesen", titel: "Verweildauer abgelesen", text: "Über 15 Minuten im Schnitt heißt: Die Stimmung trägt. Darunter liegt es fast nie am Thema, sondern am Anfang.", stufe: "profi" }, + { schluessel: "erkenntnis-zurueck-in-die-vorlage", titel: "Erkenntnis zurueck in die Vorlage", text: "Was sich bewaehrt hat, wird Teil des Ablaufs -- sonst muss man es sich jedes Mal neu merken.", stufe: "meister" }, + { schluessel: "verweildauer-abgelesen", titel: "Verweildauer abgelesen", text: "Über 15 Minuten im Schnitt heißt: Die Stimmung trägt. Darunter liegt es fast nie am Thema, sondern am Anfang.", stufe: "profi" }, { schluessel: "anteil-neuer-zuschauer", titel: "Anteil neuer Zuschauer", text: "Wie viele waren noch nie da? Ohne neue wächst nichts, ohne alte trägt nichts. Beide Zahlen gehören nebeneinander.", stufe: "profi" }, { schluessel: "wiederkehrer-ueber-vier-wochen", titel: "Wiederkehrer über vier Wochen", text: "Wie viele kommen ein zweites Mal? Diese eine Zahl sagt mehr über den Kanal als jeder Spitzenwert.", stufe: "meister" }, - ], }; @@ -311,10 +312,11 @@ export const COMMUNITY_PUNKTE = { { schluessel: "automatikfilter-stufe-begruendet", titel: "Automatikfilter: Stufe gewaehlt und begruendet", text: "Die Stufe des automatischen Filters ist eine Abwaegung, keine Voreinstellung: zu scharf loescht Harmloses, zu lasch laesst durch. Die Wahl gehoert aufgeschrieben.", stufe: "profi" }, { schluessel: "wer-eskaliert-an-wen", titel: "Wer eskaliert an wen", text: "Ab wann entscheidet die Moderation nicht mehr selbst? Bei Bedrohung, Rechtsverstoss oder Unsicherheit geht es weiter -- und es muss klar sein, an wen.", stufe: "profi" }, { schluessel: "moderatoren-eingewiesen-nicht-nur-ernannt", titel: "Moderatoren eingewiesen, nicht nur ernannt", text: "Rechte vergeben ist der kleinere Teil. Dazu gehoeren: die Regeln schriftlich, die Eskalationsleiter, die Werkzeuge einmal gezeigt.", stufe: "meister" }, - { schluessel: "ernstfall-einmal-geprobt", titel: "Ernstfall einmal geprobt", text: "Einmal durchspielen, wer was tut, wenn es kippt. Im Ernstfall zum ersten Mal darueber nachzudenken ist zu spaet.", stufe: "meister" }, /* --- Aus der Recherche vom 09.09.2026 --- */ + { schluessel: "ernstfall-einmal-geprobt", titel: "Ernstfall einmal geprobt", text: "Einmal durchspielen, wer was tut, wenn es kippt. Im Ernstfall zum ersten Mal darueber nachzudenken ist zu spaet.", stufe: "meister" }, + + /* --- Aus der Recherche vom 09.09.2026 --- */ { schluessel: "erlaubte-woerter-gepflegt", titel: "Erlaubte Wörter gepflegt", text: "Der Filter fängt auch Wörter, die bei euch harmlos sind. Eine kurze Ausnahmeliste verhindert, dass die Stammleute verstummen.", stufe: "fort" }, { schluessel: "eigener-kanal-fuers-mod-team", titel: "Eigener Kanal fürs Mod-Team", text: "Absprachen gehören nicht in den öffentlichen Chat. Ein eigener Raum, in dem gefragt werden darf, ohne dass alle mitlesen.", stufe: "profi" }, - ], aktion: [ { schluessel: "neue-begruessen", titel: "Neue begruessen", text: "Wer zum ersten Mal da ist, wird angesprochen. Kostet zwei Sekunden und entscheidet, ob jemand bleibt.", stufe: "anfaenger" }, @@ -322,9 +324,9 @@ export const COMMUNITY_PUNKTE = { { schluessel: "etwas-geben-das-nur-hier-gibt", titel: "Etwas geben, das nur hier gibt", text: "Ein Insider, ein Ritual, ein Spitzname. Community entsteht aus Gemeinsamem.", stufe: "fort" }, { schluessel: "ausserhalb-des-streams-antworten", titel: "Ausserhalb des Streams antworten", text: "Kommentare und Nachrichten -- die Community lebt auch zwischen den Sendungen.", stufe: "fort" }, { schluessel: "wiederkehrende-rubrik-mit-festem-termin", titel: "Wiederkehrende Rubrik mit festem Termin", text: "Etwas, das immer am selben Tag stattfindet. Ein Termin, auf den man sich freuen kann, bindet staerker als guter Einzelinhalt.", stufe: "profi" }, - { schluessel: "uebergabe-an-einen-zweiten-geregelt", titel: "Uebergabe an einen Zweiten geregelt", text: "Was passiert mit der Community, wenn man zwei Wochen ausfaellt? Wer nicht ersetzbar ist, kann nicht krank werden.", stufe: "meister" }, { schluessel: "begruessung-in-den-ersten-minuten", titel: "Begrüßung in den ersten Minuten", text: "Nicht wenn Zeit ist, sondern sofort. Wer in der ersten Minute angesprochen wird, bleibt deutlich länger.", stufe: "anfaenger" }, + { schluessel: "uebergabe-an-einen-zweiten-geregelt", titel: "Uebergabe an einen Zweiten geregelt", text: "Was passiert mit der Community, wenn man zwei Wochen ausfaellt? Wer nicht ersetzbar ist, kann nicht krank werden.", stufe: "meister" }, + { schluessel: "begruessung-in-den-ersten-minuten", titel: "Begrüßung in den ersten Minuten", text: "Nicht wenn Zeit ist, sondern sofort. Wer in der ersten Minute angesprochen wird, bleibt deutlich länger.", stufe: "anfaenger" }, { schluessel: "ein-ritual-das-jeder-kennt", titel: "Ein Ritual, das jeder kennt", text: "Derselbe Einstieg, dieselbe Verabschiedung. Wiedererkennung ist der halbe Grund wiederzukommen.", stufe: "fort" }, - ], konflikt: [ { schluessel: "ruhig-bleiben-und-nicht-oeffentlich-stre", titel: "Ruhig bleiben und nicht oeffentlich streiten", text: "Im Chat gewinnt niemand. Kurz ansagen, dann privat klaeren.", stufe: "anfaenger" }, @@ -333,8 +335,8 @@ export const COMMUNITY_PUNKTE = { { schluessel: "betreuung-einbeziehen", titel: "Betreuung einbeziehen", text: "Bei allem, was groesser ist als ein blöder Kommentar. Dafuer ist sie da.", stufe: "anfaenger" }, { schluessel: "danach-was-hat-gefehlt", titel: "Danach: was hat gefehlt?", text: "Regel unklar, niemand zum Eingreifen da, zu spaet gemerkt -- daraus wird der naechste Punkt oben.", stufe: "fort" }, { schluessel: "entscheidung-dokumentiert", titel: "Entscheidung dokumentiert, nicht nur getroffen", text: "Wer, wann, warum, welche Stufe. Beim zweiten Mal mit derselben Person ist das der Unterschied zwischen 'wir haben da was' und 'ich glaube'.", stufe: "profi" }, - { schluessel: "regel-nachgezogen", titel: "Regel nachgezogen, damit es nicht wiederkommt", text: "Ein Vorfall, der von keiner Regel gedeckt war, ist eine Luecke -- kein Einzelfall. Die Regel wird ergaenzt, nicht der Fall vergessen.", stufe: "meister" }, { schluessel: "gleich-behandelt-nicht-nach-sympathie", titel: "Gleich behandelt, nicht nach Sympathie", text: "Dieselbe Regel für Stammgast und Fremden. Eine Ausnahme kostet mehr Vertrauen als zehn Sperren.", stufe: "fort" }, - + { schluessel: "regel-nachgezogen", titel: "Regel nachgezogen, damit es nicht wiederkommt", text: "Ein Vorfall, der von keiner Regel gedeckt war, ist eine Luecke -- kein Einzelfall. Die Regel wird ergaenzt, nicht der Fall vergessen.", stufe: "meister" }, + { schluessel: "gleich-behandelt-nicht-nach-sympathie", titel: "Gleich behandelt, nicht nach Sympathie", text: "Dieselbe Regel für Stammgast und Fremden. Eine Ausnahme kostet mehr Vertrauen als zehn Sperren.", stufe: "fort" }, ], }; @@ -356,12 +358,13 @@ export const TECHNIK_PUNKTE = { { schluessel: "alles-einmal-komplett-getestet", titel: "Alles einmal komplett getestet", text: "Eine Probesendung von fuenf Minuten, aufgezeichnet und angesehen.", stufe: "anfaenger" }, { schluessel: "tonfilter-in-der-richtigen-reihenfolge", titel: "Tonfilter in der richtigen Reihenfolge", text: "Erst Rauschunterdrueckung, dann Kompressor, dann Rauschsperre. Die Reihenfolge ist nicht beliebig: Ein Kompressor vor der Rauschunterdrueckung hebt das Rauschen mit an.", stufe: "profi" }, { schluessel: "hardware-encoder-statt-prozessor", titel: "Hardware-Encoder statt Prozessor", text: "Wenn die Grafikkarte einen eigenen Encoder hat, uebernimmt der das Kodieren -- der Prozessor bleibt frei fuer alles andere. Das ist der groesste Einzelgewinn an Stabilitaet.", stufe: "profi" }, - { schluessel: "szenen-und-filter-gesichert", titel: "Szenen und Filter gesichert", text: "Die ganze Einrichtung als Sammlung exportiert und abgelegt. Nach einem Rechnerwechsel ist das der Unterschied zwischen zehn Minuten und einem Abend.", stufe: "meister" }, /* --- Aus der Recherche vom 09.09.2026 --- */ + { schluessel: "szenen-und-filter-gesichert", titel: "Szenen und Filter gesichert", text: "Die ganze Einrichtung als Sammlung exportiert und abgelegt. Nach einem Rechnerwechsel ist das der Unterschied zwischen zehn Minuten und einem Abend.", stufe: "meister" }, + + /* --- Aus der Recherche vom 09.09.2026 --- */ { schluessel: "mikrofon-abstand-und-winkel", titel: "Abstand und Winkel des Mikrofons", text: "Fünf bis zehn Zentimeter, leicht seitlich gesprochen. Näher dran und leiser aufgedreht ist die beste Verbesserung, die nichts kostet.", stufe: "anfaenger" }, { schluessel: "tonspur-mit-160-kbit", titel: "Tonspur mit 160 kbit/s", text: "Darunter klingt Sprache dumpf, darüber nimmt es dem Bild die Bandbreite. 160 ist der übliche Wert für Stimme.", stufe: "fort" }, { schluessel: "bildrate-im-spiel-begrenzt", titel: "Bildrate im Spiel begrenzt", text: "Ein ungebremstes Spiel nimmt dem Encoder die Luft. Ein Deckel bei 60 oder 120 behebt die meisten Ruckler.", stufe: "fort" }, { schluessel: "lautheit-gemessen", titel: "Lautheit gemessen, nicht geschätzt", text: "Zielbereich minus 20 bis minus 24 LUFS, Begrenzer bei minus 3. Wer nach Gefühl aussteuert, ist entweder leiser als alle anderen oder übersteuert.", stufe: "profi" }, - ], problem: [ { schluessel: "ton-weg-oder-verzerrt", titel: "Ton weg oder verzerrt", text: "Wann genau, welches Geraet, was lief gleichzeitig? Ohne das findet es niemand.", stufe: "anfaenger" }, @@ -369,17 +372,17 @@ export const TECHNIK_PUNKTE = { { schluessel: "stream-bricht-ab", titel: "Stream bricht ab", text: "Zeitpunkt notieren und im Protokoll des Programms nachsehen.", stufe: "fort" }, { schluessel: "ton-und-bild-laufen-auseinander", titel: "Ton und Bild laufen auseinander", text: "Meist ein Puffer. Wann faellt es auf, sofort oder erst spaeter?", stufe: "fort" }, { schluessel: "verworfene-bilder-abgelesen", titel: "Verworfene Bilder abgelesen, nicht geraten", text: "Das Programm zeigt, ob Bilder wegen der Leitung oder wegen des Kodierens verloren gehen. Das sind zwei verschiedene Fehler mit zwei verschiedenen Loesungen.", stufe: "profi" }, - { schluessel: "ausfall-mit-datum-festgehalten", titel: "Ausfall mit Datum festgehalten", text: "Jeder Ausfall bekommt einen Eintrag. Erst die Liste zeigt, ob es dasselbe Problem ist oder drei verschiedene.", stufe: "meister" }, { schluessel: "statistikfenster-offen-gehabt", titel: "Statistikfenster offen gehabt", text: "Verworfene Bilder und Renderverzögerung stehen dort in Echtzeit. Hinterher raten kostet mehr Zeit als vorher hinsehen.", stufe: "fort" }, + { schluessel: "ausfall-mit-datum-festgehalten", titel: "Ausfall mit Datum festgehalten", text: "Jeder Ausfall bekommt einen Eintrag. Erst die Liste zeigt, ob es dasselbe Problem ist oder drei verschiedene.", stufe: "meister" }, + { schluessel: "statistikfenster-offen-gehabt", titel: "Statistikfenster offen gehabt", text: "Verworfene Bilder und Renderverzögerung stehen dort in Echtzeit. Hinterher raten kostet mehr Zeit als vorher hinsehen.", stufe: "fort" }, { schluessel: "netz-oder-rechner-unterschieden", titel: "Netz oder Rechner unterschieden", text: "Verworfene Bilder kommen vom Netz, ausgelassene vom Encoder. Verschiedene Ursachen, verschiedene Lösungen – erst trennen, dann handeln.", stufe: "profi" }, - ], loesung: [ { schluessel: "was-hat-es-behoben", titel: "Was hat es behoben?", text: "Genau die eine Aenderung aufschreiben, nicht alles was probiert wurde.", stufe: "anfaenger" }, { schluessel: "kommt-es-wieder", titel: "Kommt es wieder?", text: "Beim naechsten Mal nachsehen. Einmal weg ist nicht geloest.", stufe: "fort" }, { schluessel: "einstellung-gesichert", titel: "Einstellung gesichert", text: "Damit sie nach einem Update nicht wieder weg ist.", stufe: "fort" }, { schluessel: "genau-eine-aenderung-je-versuch", titel: "Genau eine Aenderung je Versuch", text: "Wer drei Dinge gleichzeitig aendert und es geht wieder, weiss nicht warum -- und beim naechsten Mal steht man wieder am Anfang.", stufe: "profi" }, - { schluessel: "loesung-als-anleitung-abgelegt", titel: "Loesung als Anleitung abgelegt", text: "So aufgeschrieben, dass jemand anderes sie nachvollziehen kann. Eine Loesung im Kopf einer Person ist keine Loesung des Teams.", stufe: "meister" }, { schluessel: "profile-ausserhalb-gesichert", titel: "Profile außerhalb des Rechners gesichert", text: "Szenen, Filter und Profile auf einen zweiten Datenträger. Ein Rechnerschaden kostet sonst zwei Abende Wiederaufbau.", stufe: "meister" }, - + { schluessel: "loesung-als-anleitung-abgelegt", titel: "Loesung als Anleitung abgelegt", text: "So aufgeschrieben, dass jemand anderes sie nachvollziehen kann. Eine Loesung im Kopf einer Person ist keine Loesung des Teams.", stufe: "meister" }, + { schluessel: "profile-ausserhalb-gesichert", titel: "Profile außerhalb des Rechners gesichert", text: "Szenen, Filter und Profile auf einen zweiten Datenträger. Ein Rechnerschaden kostet sonst zwei Abende Wiederaufbau.", stufe: "meister" }, ], }; export const CALL_LEITFAEDEN = [ @@ -415,6 +418,190 @@ function angemeldet(req, res, next) { req.person = person; next(); } + +/* ===================================================================== + AUFGABEN-VORLAGEN — vier Bereiche, vier Stufen, drei Aufgaben je Feld + + Filipe (09.09.2026): "ich will dass du die vier klassen ueberall mit + aufgaben perfektionnierst ... die 4 kategorien ueberall perfekt + angepasst, mit perfekt kategorien und aufgaben." + + WARUM ES DAS BRAUCHT. Die Checkliste sagt, WAS sitzen muss. Sie sagt + nicht, was jemand als Naechstes TUT. Zwischen "Ton ist der Grund + Nummer eins" und einem Abend, an dem der Ton wirklich besser wird, + liegt eine Aufgabe mit Frist und Verantwortlichem -- und die gab es + bisher nur, wenn jemand sie von Hand tippte. + + WARUM DREI JE FELD UND NICHT ZEHN. Wer zehn Aufgaben bekommt, macht + keine. Drei sind ein Abend, eine Woche, ein Monat -- in dieser + Reihenfolge, und die Fristen sind entsprechend gestaffelt. + + WIE DIE STUFEN GEMEINT SIND (Dreyfus-Modell, wie in der Checkliste): + anfaenger Was ohne Weiteres nicht geht. Meist an einem Abend zu tun. + fort Aus Zufall wird Routine. Braucht Wiederholung, also Wochen. + profi Gemessen statt geschaetzt: ein Wert, ein Versuch, ein Beleg. + meister Was ohne dich weiterlaeuft: Vorlage, Vertretung, Ablage. + + `tage` ist der Vorschlag fuer die Frist, gerechnet ab dem Tag der + Uebernahme. Er ist kein Zierrat: Eine Aufgabe ohne Frist ist ein + Wunsch. Die Zahlen folgen der Stufe -- Anfaengeraufgaben in Tagen, + Meisteraufgaben in Wochen. + + Die Sachaussagen stammen aus derselben Recherche wie die + Checklistenpunkte (siehe dort): Lautheit -20 bis -24 LUFS, + Tonspur 160 kbit/s, Ankuendigung 30 bis 60 Minuten vorher, + Verweildauer ueber 15 Minuten als Zeichen, dass die Stimmung traegt, + Eskalation in vier Stufen. + ===================================================================== */ + +export const AUFGABEN_VORLAGEN = { + live: { + anfaenger: [ + { schluessel: "live-ton-aufnehmen-und-anhoeren", titel: "Ton aufnehmen und anhören", tage: 2, + text: "Eine Minute mit dem echten Aufbau aufnehmen und mit Kopfhörern anhören. Nicht während des Streams – da hört man sich selbst nie." }, + { schluessel: "live-licht-einmal-richtig-stellen", titel: "Licht einmal richtig stellen", tage: 2, + text: "Gesicht heller als der Hintergrund, kein Fenster im Rücken. Ein Foto vorher und nachher machen – der Unterschied ist größer, als man denkt." }, + { schluessel: "live-titel-und-thema-vorher", titel: "Titel und Thema vorher aufschreiben", tage: 3, + text: "Für die nächsten drei Streams je eine Zeile. Wer den Titel liest, muss wissen, ob es für ihn ist." }, + ], + fort: [ + { schluessel: "live-sendeplan-vier-wochen", titel: "Sendeplan für vier Wochen festlegen", tage: 7, + text: "Zwei feste Tage schlagen fünf zufällige. In den Kalender eintragen, damit die Betreuung mitplanen kann." }, + { schluessel: "live-ankuendigung-zur-routine", titel: "Ankündigung zur Routine machen", tage: 7, + text: "30 bis 60 Minuten vor jedem Stream ein kurzes Video. Es bündelt die, die ohnehin kommen wollten – und ein voller Start zieht neue nach." }, + { schluessel: "live-ablauf-in-drei-bloecken", titel: "Ablauf in drei Blöcken", tage: 5, + text: "Anfang, Mitte, Schluss – je zwei Stichworte. Einmal danach senden und hinterher notieren, wo es gehakt hat." }, + ], + profi: [ + { schluessel: "live-upload-messen-bitrate-setzen", titel: "Upload messen, Bitrate setzen", tage: 3, + text: "Upload messen und höchstens 75 Prozent davon als Bitrate einstellen. Darüber verwirft die Leitung Bilder, sobald sonst noch etwas läuft." }, + { schluessel: "live-erste-dreissig-sekunden", titel: "Die ersten dreißig Sekunden ausformulieren", tage: 5, + text: "Wörtlich aufschreiben, nicht stichwortartig. In den ersten Sekunden entscheidet sich, ob jemand bleibt – das ist der einzige Teil, den man auswendig können sollte." }, + { schluessel: "live-verweildauer-vergleichen", titel: "Verweildauer der letzten fünf Streams vergleichen", tage: 7, + text: "Durchschnittliche Verweildauer nebeneinanderlegen. Über 15 Minuten heißt: Die Stimmung trägt. Darunter liegt es fast nie am Thema, sondern am Anfang." }, + ], + meister: [ + { schluessel: "live-ablauf-als-vorlage", titel: "Ablauf als Vorlage ablegen", tage: 14, + text: "So aufschreiben, dass eine Vertretung den Stream fahren könnte. Wer nicht ersetzbar ist, kann nicht krank werden." }, + { schluessel: "live-ein-ziel-vier-wochen", titel: "Ein Ziel in einer Zahl, vier Wochen verfolgen", tage: 28, + text: "Eine einzige Zahl je Stream – Verweildauer, neue Zuschauer oder Wiederkehrer. Vier Wochen dieselbe, sonst vergleicht man nichts." }, + { schluessel: "live-ausweichweg-proben", titel: "Ausweichweg einrichten und proben", tage: 14, + text: "Zweites Gerät, zweite Leitung oder ein Ersatzstream. Einmal wirklich umschalten – ein ungeprobter Ausweichweg ist keiner." }, + ], + }, + + content: { + anfaenger: [ + { schluessel: "content-drei-ideen-mit-termin", titel: "Drei Ideen übernehmen und terminieren", tage: 3, + text: "Aus der Ideenliste drei aussuchen und jeder ein Datum geben. Eine Idee ohne Termin ist eine Notiz." }, + { schluessel: "content-ein-video-mit-haken", titel: "Ein Video mit Haken in Sekunde eins", tage: 5, + text: "Der erste Satz ist eine Frage, eine Behauptung oder eine Zahl – kein „Hey Leute“. Über 70 Prozent entscheiden in den ersten drei Sekunden." }, + { schluessel: "content-bester-moment-als-clip", titel: "Den besten Moment als Clip schneiden", tage: 3, + text: "Aus dem letzten LIVE einen Ausschnitt unter 60 Sekunden. Der Feed hat mehr Reichweite als der Stream – er ist der Weg dorthin." }, + ], + fort: [ + { schluessel: "content-saeulen-anlegen", titel: "Content-Säulen anlegen und drei Wochen halten", tage: 14, + text: "Rund 70 Prozent Wert, 20 Prozent Community, 10 Prozent eigene Sache. Drei Wochen danach planen und dann nachsehen, was es verändert hat." }, + { schluessel: "content-fuenf-haken-vorher", titel: "Fünf Haken schreiben, bevor gedreht wird", tage: 5, + text: "Erst die ersten Sätze, dann die Kamera. Wer beim Drehen nach dem Einstieg sucht, findet ihn nicht." }, + { schluessel: "content-format-zweimal-wiederholen", titel: "Ein Format zweimal wiederholen", tage: 14, + text: "Dasselbe Format, andere Sache. Erst beim zweiten Mal sieht man, ob das Format trägt oder nur das Thema getragen hat." }, + ], + profi: [ + { schluessel: "content-intro-retention-ablesen", titel: "Intro-Retention der letzten zehn Videos ablesen", tage: 7, + text: "Wie viele bleiben über die ersten drei Sekunden hinaus? Gute Werte liegen bei 70 Prozent und mehr. Die drei schlechtesten nebeneinanderlegen und den Haken vergleichen." }, + { schluessel: "content-zwei-haken-gegeneinander", titel: "Zwei Haken gegeneinander testen", tage: 10, + text: "Gleiches Video, gleicher Schnitt, gleiche Zeit – nur der erste Satz anders. Ein Versuch, eine Änderung, sonst weiß man hinterher nichts." }, + { schluessel: "content-untertitel-ohne-ton", titel: "Untertitel prüfen: ohne Ton lesbar", tage: 5, + text: "Alle Videos einmal stumm durchsehen. Der größte Teil wird ohne Ton geschaut – was dann nicht ankommt, kommt gar nicht an." }, + ], + meister: [ + { schluessel: "content-redaktionsplan-vier-wochen", titel: "Redaktionsplan für vier Wochen", tage: 21, + text: "So geschrieben, dass jemand anderes ihn fahren könnte: Thema, Format, Säule, Termin. Das ist der Unterschied zwischen einem Plan und einer Liste." }, + { schluessel: "content-vorlagensammlung", titel: "Vorlagensammlung anlegen", tage: 21, + text: "Haken, Schnittfolge, Endkarte, Beschreibungstext. Einmal gut gemacht und danach zehnmal benutzt – das ist der ganze Unterschied zwischen Fleiß und System." }, + { schluessel: "content-zweiten-einweisen", titel: "Einen Zweiten einweisen", tage: 28, + text: "Jemand, der Clips schneiden kann, ohne jedes Mal zu fragen. Dafür braucht es die Vorlagensammlung – deshalb steht sie davor." }, + ], + }, + + technik: { + anfaenger: [ + { schluessel: "technik-mikrofon-abstand", titel: "Mikrofon auf fünf bis zehn Zentimeter", tage: 1, + text: "Näher heran und die Verstärkung herunter, leicht seitlich gesprochen. Das ist die wirksamste Verbesserung, die nichts kostet." }, + { schluessel: "technik-kamera-einmal-einstellen", titel: "Kamera scharf stellen und belichten", tage: 2, + text: "Automatik aus, einmal von Hand einstellen. Eine Kamera, die während des Streams nachregelt, pumpt im Bild." }, + { schluessel: "technik-kompletter-durchlauf", titel: "Einen kompletten Durchlauf testen", tage: 3, + text: "Ton, Bild, Verbindung, Szenenwechsel – zehn Minuten privat aufnehmen und ansehen. Nicht kurz vor dem Stream." }, + ], + fort: [ + { schluessel: "technik-szenen-anlegen", titel: "Vier Szenen anlegen", tage: 5, + text: "Start, Haupt, Pause, Ende. Wer während des Streams Fenster sucht, ist für zwei Minuten weg – und die Zuschauer auch." }, + { schluessel: "technik-tonspur-160", titel: "Tonspur auf 160 kbit/s stellen", tage: 2, + text: "Darunter klingt Sprache dumpf, darüber nimmt es dem Bild die Bandbreite. 160 ist der übliche Wert für Stimme." }, + { schluessel: "technik-bildrate-begrenzen", titel: "Bildrate im Spiel begrenzen", tage: 2, + text: "Deckel bei 60 oder 120 Bildern. Ein ungebremstes Spiel nimmt dem Encoder die Luft – das behebt die meisten Ruckler." }, + ], + profi: [ + { schluessel: "technik-lautheit-messen", titel: "Lautheit messen und einstellen", tage: 5, + text: "Zielbereich minus 20 bis minus 24 LUFS, Begrenzer bei minus 3. Wer nach Gefühl aussteuert, ist entweder leiser als alle anderen oder übersteuert." }, + { schluessel: "technik-filterkette-pruefen", titel: "Filterkette prüfen", tage: 5, + text: "Reihenfolge: Rauschsperre, Entrauschen, Kompressor, Begrenzer. Falsch sortiert arbeiten sie gegeneinander – und man hört nur, dass etwas nicht stimmt." }, + { schluessel: "technik-statistik-eine-woche", titel: "Eine Woche Statistikfenster mitlaufen lassen", tage: 7, + text: "Verworfene Bilder und Renderverzögerung nach jedem Stream notieren. Verworfen kommt vom Netz, ausgelassen vom Encoder – verschiedene Ursachen, verschiedene Lösungen." }, + ], + meister: [ + { schluessel: "technik-profile-sichern", titel: "Szenen, Filter und Profile sichern", tage: 7, + text: "Auf einen zweiten Datenträger, nicht auf denselben Rechner. Ein Rechnerschaden kostet sonst zwei Abende Wiederaufbau." }, + { schluessel: "technik-ersatzweg-einrichten", titel: "Ersatzweg einrichten", tage: 21, + text: "Zweites Gerät oder zweite Leitung, die übernehmen kann. Einmal wirklich umschalten – sonst weiß man nicht, ob es geht." }, + { schluessel: "technik-stoerungsliste", titel: "Störungsliste anlegen", tage: 14, + text: "Symptom, Ursache, Lösung – drei Spalten. Beim zweiten Mal ist derselbe Ausfall dann kein Abend mehr, sondern fünf Minuten." }, + ], + }, + + community: { + anfaenger: [ + { schluessel: "community-drei-regeln", titel: "Drei Regeln aufschreiben und sichtbar machen", tage: 2, + text: "Kurz, verständlich, an einer Stelle, die man findet. Drei Sätze reichen – wer zehn Regeln schreibt, hat keine." }, + { schluessel: "community-erste-minute-begruessen", titel: "In der ersten Minute begrüßen", tage: 1, + text: "Neue beim Namen ansprechen, sobald sie da sind – nicht wenn Zeit ist. Wer angesprochen wird, bleibt deutlich länger." }, + { schluessel: "community-wer-greift-ein", titel: "Klären, wer eingreifen kann", tage: 3, + text: "Eine zweite Person mit Rechten während des Streams – oder die ausdrückliche Feststellung, dass keine da ist. Beides ist besser als Unklarheit." }, + ], + fort: [ + { schluessel: "community-wortfilter-einrichten", titel: "Wortfilter einrichten und beobachten", tage: 7, + text: "Mittlere Stufe, eine Woche mitlesen, dann nachjustieren. Zu scharf löscht Harmloses, zu lasch lässt durch – beides merkt man erst im Betrieb." }, + { schluessel: "community-erlaubte-woerter", titel: "Ausnahmeliste erlaubter Wörter pflegen", tage: 7, + text: "Der Filter fängt auch Wörter, die bei euch harmlos sind. Ohne Ausnahmeliste verstummen ausgerechnet die Stammleute." }, + { schluessel: "community-feste-rubrik", titel: "Eine feste Rubrik mit Termin einführen", tage: 14, + text: "Etwas, das immer am selben Tag stattfindet. Ein Termin, auf den man sich freuen kann, bindet stärker als guter Einzelinhalt." }, + ], + profi: [ + { schluessel: "community-eskalationsleiter", titel: "Eskalationsleiter schriftlich festlegen", tage: 7, + text: "Vier Stufen in dieser Reihenfolge: ansprechen, Nachricht löschen, kurze Auszeit, Sperre. Wer sie erst im Moment festlegt, entscheidet ungleich." }, + { schluessel: "community-mod-kanal", titel: "Eigenen Kanal fürs Mod-Team einrichten", tage: 5, + text: "Absprachen gehören nicht in den öffentlichen Chat. Ein Raum, in dem gefragt werden darf, ohne dass alle mitlesen." }, + { schluessel: "community-entscheidungen-dokumentieren", titel: "Entscheidungen dokumentieren", tage: 14, + text: "Wer, wann, warum, welche Stufe. Beim zweiten Mal mit derselben Person ist das der Unterschied zwischen „wir haben da was“ und „ich glaube“." }, + ], + meister: [ + { schluessel: "community-moderatoren-einweisen", titel: "Moderatoren einweisen", tage: 14, + text: "Regeln schriftlich, Eskalationsleiter, Werkzeuge einmal gezeigt. Rechte zu vergeben ist der kleinere Teil." }, + { schluessel: "community-ernstfall-proben", titel: "Ernstfall einmal durchspielen", tage: 21, + text: "Wer tut was, wenn es kippt? Im Ernstfall zum ersten Mal darüber nachzudenken ist zu spät." }, + { schluessel: "community-uebergabe-regeln", titel: "Übergabe regeln", tage: 21, + text: "Wer führt die Community, wenn du zwei Wochen ausfällst? Schriftlich, mit Zugängen – sonst ist es kein Plan, sondern eine Hoffnung." }, + ], + }, +}; + +/* Alle Aufgabenvorlagen in einer flachen Liste -- fuer Pruefungen und + fuer das Nachschlagen eines einzelnen Schluessels. */ +export const AUFGABEN_VORLAGEN_FLACH = Object.entries(AUFGABEN_VORLAGEN) + .flatMap(([bereich, stufen]) => Object.entries(stufen) + .flatMap(([stufe, liste]) => liste.map((v) => ({ ...v, bereich, stufe })))); + vorlagenRouter.use("/workspace/api/vorlagen", angemeldet); vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => { @@ -424,6 +611,7 @@ vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => { community: COMMUNITY_PUNKTE, technik: TECHNIK_PUNKTE, calls: CALL_LEITFAEDEN, + aufgaben: AUFGABEN_VORLAGEN, }); }); @@ -502,6 +690,42 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft, if (!liste) return res.status(400).json({ fehler: "Unbekannter Abschnitt." }); for (const v of liste) einfuegen(bereich, unterart, v.titel, v.text); + } else if (art === "aufgabe" || art === "aufgaben_alle") { + /* ECHTE AUFGABEN, KEINE EINTRAEGE. + + Alles darueber legt Eintraege an (Ideen, Notizen, Befunde). + Eine Aufgabe ist etwas anderes: Sie hat einen + Verantwortlichen, eine Frist und einen Status, der sich + bewegt. Deshalb eine eigene Zeile in `aufgaben` und nicht + noch eine Art in `eintraege` -- sonst stuende dieselbe Sache + an zwei Orten, und die beiden liefen auseinander. + + OHNE CREATOR KEINE AUFGABE. Eine Aufgabe ohne Adressaten ist + ein Zettel. Der Server lehnt sie deshalb ab, statt sie + herrenlos anzulegen. */ + const bereich = String(req.body.bereich || ""); + const stufe = String(req.body.stufe || ""); + const feld = (AUFGABEN_VORLAGEN[bereich] || {})[stufe]; + if (!feld) return res.status(400).json({ fehler: "Unbekannter Bereich oder unbekannte Stufe." }); + if (!creatorId) return res.status(400).json({ fehler: "Bitte zuerst einen Creator wählen." }); + const auswahl = art === "aufgaben_alle" ? feld : [feld[nr]]; + if (auswahl.some((v) => !v)) return res.status(400).json({ fehler: "Unbekannte Vorlage." }); + for (const v of auswahl) { + /* Die Frist wird aus `tage` gerechnet, in ORTSZEIT. Ueber + toISOString kaeme zwischen Mitternacht und zwei Uhr der + Vortag heraus -- derselbe Fehler, der im Haus schon einmal + eine Pruefung falsch rot gemacht hat. */ + const d = new Date(Date.now() + (v.tage || 7) * 86400_000); + const frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + + `-${String(d.getDate()).padStart(2, "0")}`; + db().prepare(`INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, frist, erstellt, erstellt_von) + VALUES (?,?,'offen',?,?,?,?,?)`) + .run(v.titel, v.text, stufe === "anfaenger" ? "hoch" : "mittel", + creatorId, frist, jetzt(), req.person.id); + angelegt.push(v.titel); + } + } else { return res.status(400).json({ fehler: "Unbekannte Art." }); } diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 268a6c29..16ce8673 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -835,3 +835,83 @@ font-size: .76rem; line-height: 1.45; color: var(--text-still); } + + +/* ===================================================================== + AUFGABEN AUS DER VORLAGE (09.09.2026) + + Der Block sitzt über dem Brett und muss sich als VORRAT lesen, nicht + als weitere Spalte: eigene Fläche, ruhigere Farbe, deutlich weniger + Kontrast als die Karten darunter. Wer das Brett voll hat, soll ihn + überblättern können, ohne ihn wegklicken zu müssen. + + Die beiden Knopfreihen benutzen `.stufenleiter` aus start.css -- + dieselbe Einteilung, dieselbe Darstellung. Eine zweite Optik für + dieselben vier Bereiche wäre zwei Sachen zum Lernen statt einer. + ===================================================================== */ +.vorlagenbrett { + margin: 0 0 22px; + padding: 20px 20px 22px; + border-radius: var(--radius-klein, 14px); + /* EINE EIGENE FLAECHE, KEIN SCHLEIER. + + Erster Anlauf: nur ein Hauch Weiss ueber dem Buehnenbild. pruef-buehne + hat das zu Recht abgelehnt -- der Untergrund war gemessen + rgb(84, 69, 70), also das Foto, und die Ueberschrift kam auf 3,61:1 + statt der noetigen 4,5:1. Ein durchsichtiger Kasten hat keinen + Untergrund, sondern den, der gerade dahinterliegt. + + Jetzt eine deckende dunkle Flaeche mit einem Hauch Struktur darauf. + Der Weichzeichner dahinter macht aus dem Foto einen ruhigen Grund, + statt es einfach zuzudecken -- dieselbe Behandlung wie bei den + anderen freistehenden Bloecken im Haus. */ + background: + linear-gradient(168deg, rgba(255, 255, 255, .05), rgba(255, 255, 255, .02)), + rgba(6, 9, 16, .84); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(160, 190, 225, .16); +} +.vorlagenbrett__kopf { margin-bottom: 14px; } +.vorlagenbrett__unter { + margin-top: 6px; font-size: .86rem; line-height: 1.5; + color: var(--text-leise); + max-width: 62ch; +} +.vorlagenbrett__hinweis { + margin: 12px 0 0; font-size: .86rem; font-weight: 600; + color: var(--warn, #ff9aa2); +} +.vorlagenbrett__liste { + display: grid; gap: 12px; margin-top: 16px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} +.vorlagenbrett__karte { + display: flex; flex-direction: column; gap: 8px; + padding: 16px 18px; + border-radius: 12px; + background: rgba(4, 7, 12, .72); + border: 1px solid rgba(160, 190, 225, .14); +} +.vorlagenbrett__titel { + margin: 0; font-size: 1rem; font-weight: 700; line-height: 1.3; + color: var(--text); +} +.vorlagenbrett__text { + margin: 0; font-size: .86rem; line-height: 1.5; color: var(--text-leise); +} +/* Der Fuß trägt Frist und Knopf. `margin-top: auto` schiebt ihn nach + unten, damit die Knöpfe aller Karten auf einer Linie stehen -- auch + wenn die Texte verschieden lang sind. */ +.vorlagenbrett__fuss { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-top: auto; padding-top: 6px; +} +.vorlagenbrett__frist { + font-size: .78rem; font-weight: 700; letter-spacing: .04em; + color: var(--text-still); +} +@media (max-width: 620px) { + .vorlagenbrett { padding: 16px 14px 18px; } + .vorlagenbrett__liste { grid-template-columns: 1fr; } +} diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index b6ad0882..f8297205 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -841,5 +841,167 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe } await laden(); + await vorlagenbrettZeigen(); })(); + + /* ===================================================================== + AUFGABEN AUS DER VORLAGE + + Vier Bereiche, vier Stufen, drei Aufgaben je Feld. Der Block ist + bewusst KEINE zweite Aufgabenverwaltung: Er legt eine ganz normale + Aufgabe an, mit Frist und Verantwortlichem, und verschwindet danach + aus dem Weg. Alles Weitere macht das Brett darunter. + + WARUM DIE BEIDEN REIHEN DIESELBEN KNOEPFE SIND WIE IN DER + CHECKLISTE: Es sind dieselben vier Bereiche und dieselben vier + Stufen. Zwei verschiedene Darstellungen fuer dieselbe Einteilung + waeren zwei Sachen zum Lernen statt einer. + + OHNE CREATOR KEINE AUFGABE. Ein Creator legt sie fuer sich an, ein + Betreuer fuer den, den er oben gewaehlt hat. Ist keiner gewaehlt, + steht das da -- statt eines Knopfes, der beim Druecken einen Fehler + zeigt. + ===================================================================== */ + const BEREICHE_VORLAGE = [ + { schluessel: 'live', name: 'LIVE-Analyse' }, + { schluessel: 'content', name: 'Content-Ideen' }, + { schluessel: 'technik', name: 'Technik' }, + { schluessel: 'community', name: 'Community' }, + ]; + const STUFEN_VORLAGE = [ + { schluessel: 'anfaenger', name: 'Anfänger', + hilfe: 'Was von Anfang an sitzen muss. Meist an einem Abend zu schaffen.' }, + { schluessel: 'fort', name: 'Fortgeschritten', + hilfe: 'Aus Zufall wird Routine. Braucht Wiederholung, also Wochen.' }, + { schluessel: 'profi', name: 'Profi', + hilfe: 'Gemessen statt geschätzt: ein Wert, ein Versuch, ein Beleg.' }, + { schluessel: 'meister', name: 'Meister', + hilfe: 'Was auch ohne dich weiterläuft — Vorlage, Vertretung, Ablage.' }, + ]; + let vorlagen = null; + let vBereich = 'live'; + let vStufe = 'anfaenger'; + + async function vorlagenbrettZeigen() { + const ziel = $('vorlagenbrett'); + if (!ziel || !ich) return; + if (!vorlagen) { + try { + const a = await hole('/workspace/api/vorlagen'); + if (!a.ok) return; + vorlagen = (await a.json()).aufgaben || null; + } catch { return; } + } + if (!vorlagen) return; + ziel.hidden = false; + ziel.textContent = ''; + + const kopf = el('div', 'vorlagenbrett__kopf'); + kopf.append(el('h2', 'feldschild', 'Aufgaben aus der Vorlage')); + kopf.append(el('p', 'vorlagenbrett__unter', + 'Vier Bereiche, vier Stufen. Übernommen wird als ganz normale Aufgabe ' + + '– mit Frist, veränderbar, auf dem Brett darunter.')); + ziel.append(kopf); + + /* Reihe 1: der Bereich. */ + const r1 = el('div', 'stufenleiter'); + r1.setAttribute('role', 'group'); + r1.setAttribute('aria-label', 'Bereich wählen'); + for (const b of BEREICHE_VORLAGE) { + const k = el('button', 'stufenleiter__knopf'); + k.type = 'button'; + k.dataset.stufe = b.schluessel; + k.setAttribute('aria-pressed', String(vBereich === b.schluessel)); + k.append(el('span', 'stufenleiter__name', b.name)); + k.addEventListener('click', () => { vBereich = b.schluessel; vorlagenbrettZeigen(); }); + r1.append(k); + } + ziel.append(r1); + + /* Reihe 2: die Stufe, mit der Zahl der Aufgaben darin. */ + const r2 = el('div', 'stufenleiter'); + r2.setAttribute('role', 'group'); + r2.setAttribute('aria-label', 'Könnensstufe wählen'); + for (const st of STUFEN_VORLAGE) { + const n = ((vorlagen[vBereich] || {})[st.schluessel] || []).length; + if (!n) continue; + const k = el('button', 'stufenleiter__knopf'); + k.type = 'button'; + k.dataset.stufe = st.schluessel; + k.setAttribute('aria-pressed', String(vStufe === st.schluessel)); + k.title = st.hilfe; + k.append(el('span', 'stufenleiter__name', st.name)); + k.append(el('span', 'stufenleiter__zahl', String(n))); + k.addEventListener('click', () => { vStufe = st.schluessel; vorlagenbrettZeigen(); }); + r2.append(k); + } + ziel.append(r2); + const hilfe = STUFEN_VORLAGE.find((x) => x.schluessel === vStufe)?.hilfe; + if (hilfe) ziel.append(el('p', 'stufenleiter__hilfe', hilfe)); + + /* Für wen? */ + const fuer = ich.rolle === 'creator' ? ich.id : nurCreator; + if (!fuer) { + ziel.append(el('p', 'vorlagenbrett__hinweis', + 'Wähle oben einen Creator, dann lassen sich diese Aufgaben übernehmen.')); + } + + const liste = (vorlagen[vBereich] || {})[vStufe] || []; + const box = el('div', 'vorlagenbrett__liste'); + for (const [nr, v] of liste.entries()) { + const karte = el('div', 'vorlagenbrett__karte'); + karte.append(el('h3', 'vorlagenbrett__titel', v.titel)); + karte.append(el('p', 'vorlagenbrett__text', v.text)); + const fuss = el('div', 'vorlagenbrett__fuss'); + fuss.append(el('span', 'vorlagenbrett__frist', + v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`)); + if (fuer) { + const k = el('button', 'knopf knopf--klein', 'Übernehmen'); + k.type = 'button'; + k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer)); + fuss.append(k); + } + karte.append(fuss); + box.append(karte); + } + ziel.append(box); + + if (fuer && liste.length > 1) { + const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`); + alle.type = 'button'; + alle.style.marginTop = '12px'; + alle.addEventListener('click', () => uebernehmen(alle, { art: 'aufgaben_alle' }, fuer)); + ziel.append(alle); + } + } + + /* Ein Weg fuer beide Knoepfe -- einzeln und alle. Zwei fast gleiche + Funktionen waeren der sichere Weg dazu, dass die eine irgendwann + anders reagiert als die andere. */ + async function uebernehmen(knopf, was, fuer) { + knopf.disabled = true; + const vorher = knopf.textContent; + knopf.textContent = 'Wird übernommen …'; + try { + const a = await hole('/workspace/api/vorlagen/uebernehmen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...was, bereich: vBereich, stufe: vStufe, creator_id: fuer }), + }); + if (!a.ok) { + const f = await a.json().catch(() => ({})); + melde(f.fehler || 'Das hat nicht geklappt.'); + knopf.disabled = false; knopf.textContent = vorher; + return; + } + melde(''); + /* Erst das Brett neu laden, dann den Block -- so sieht man die + neue Aufgabe sofort dort, wo sie ab jetzt lebt. */ + await laden(); + await vorlagenbrettZeigen(); + } catch { + melde('Das hat nicht geklappt.'); + knopf.disabled = false; knopf.textContent = vorher; + } + } })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 9ea74f49..5c1f739a 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -107,6 +107,20 @@
+ + +Aufgaben werden geladen …