Compare commits

..
2 Commits
Author SHA1 Message Date
DogFatherGitandClaude Opus 5 3cda64568d Die Modi-App bekommt ihre eigene Adresse: crew.dogfather-universe.com
Filipe: "ich will dass es eine eigene app wird" -- und dann "subdomain
machen jetzt sofort". Name von ihm gewaehlt: crew. (unauffaellig).

Auf einem Ursprung laesst sich genau EINE App installieren (w3c/manifest
Nr. 1180). Derselbe Grund, aus dem der Workspace am 06.09. umgezogen ist.
Getrennt wird ueber den HOSTNAMEN: ein Dienst, eine Datenbank, ein
Verzeichnis wie bisher.

DIE REGEL STEHT AN EINER STELLE
crew-adresse.js beantwortet: Darf diese Rolle auf dieser Adresse
angemeldet sein? Eingehaengt in sitzungLesen() -- durch die Funktion geht
jedes der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
kann man in einem neuen Modul vergessen, und ein vergessener Rechteschutz
faellt nicht auf, weil dann alles geht.

  crew.      nur Modis; jeder andere bekommt 401 wie bei einem Tippfehler
  workspace. keine Modis mehr; sie bekommen den Weg zur neuen Adresse
  localhost  UNVERAENDERT

Die dritte Zeile ist der Kern: Die Regel ist eine AUFZAEHLUNG der drei
echten Adressen, nicht "alles ausser crew". Sonst wuerden sieben andere
Pruefdateien ab sofort messen, dass ein Modi nirgends hereinkommt -- gruen,
weil sie nichts mehr finden.

ZWEI APPS, ZWEI NAMEN
Beide Adressen liefern dieselben HTML-Dateien. Auf crew. wird
/workspace/app.webmanifest serverseitig auf crew.webmanifest umgebogen --
so braucht keine der 30 Seiten eine zweite Zeile, die man bei der 31.
vergisst. "Team Dogi" statt "Creator Workspace", eigenes Zeichen:
derselbe Husky, aber ohne Chili (die gehoert zu Spicy Media, nicht zu
ihnen) und im Modi-Ton #5f8a9f.

tools/crew-symbol.mjs erzeugt die sechs Symbole und bricht ab, wenn sie
sich zu weniger als 10 % vom Workspace-Symbol unterscheiden. Gemessen:
43 bis 53 %.

GEMESSEN
pruef-crew-adresse    74 Pruefungen, 0 Fehler -- mit Gegenprobe: der Modi
                      bekommt in der Datenbank die Rolle 'manager', danach
                      MUSS dieselbe Sitzung auf crew. ins Leere laufen
pruef-rollen          245, unveraendert (die Regel ist lokal wirkungslos)
pruef-workspace-umzug 31 Faelle
pruef-zwischenspeicher 21 -- ein Stempel im Haus, jetzt ueber 23 Dateien

Der Server kennt die Adresse damit. DNS und Caddy bleiben Filipes Schritt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 13:07:12 +02:00
DogFatherGitandClaude Opus 5 05f262eb61 Modis verteilen Aufgaben statt sie zu bekommen -- und Angebote zur Entscheidung
Die drei Beurteilungs-Bereiche liefen bisher in die falsche Richtung: Sie
bewerteten die Modis. Filipe: "die modis sind ja da um mir zu helfen."
Also umgedreht.

WAS SICH GEDREHT HAT
- Alle 101 Punkte sind jetzt Beobachtungen ueber den Stream, nicht
  Pflichten des Modis ("Der Ton blieb verstaendlich" statt "Ton geprueft").
- Nur der Modi selbst drueckt auf seiner Liste. Wer sonst darauf zeigt,
  bekommt 403 und den Weg zur Team-Lage -- bewerten wird hier niemand.
- "Verbessern" landet als Eingang bei DogFather. Ein Klick macht daraus
  eine Aufgabe mit dem Satz des Modis im Text, oder eine Absage mit Grund.
  Beides schreibt eine Nachricht zurueck, damit der Modi sieht: angekommen.

ANGEBOTE
Neuer Bereich, in dem Modis planen und vorschlagen: Nutzen und Aufwand
statt Bewertung und Dringlichkeit, dazu ein Feld "was DogFather danach
tun muss". Wird ein Angebot angenommen, entstehen zwei Aufgaben -- eine
beim Modi zum Umsetzen, eine bei DogFather aus genau diesem Feld.

GEMESSEN
pruef-modi-checkliste  59 Pruefungen, 0 Fehler (neu geschrieben)
pruef-rollen          245 statt 244 -- der Zuwachs ist die neue
                      Angebote-Kachel, alle 11 Modi-Kacheln kommen an
pruef-modi-ideen       30, pruef-modi-verborgen 75, pruef-bereiche-lesend: gruen

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-10 12:52:45 +02:00
42 changed files with 2340 additions and 638 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+184
View File
@@ -0,0 +1,184 @@
/* =====================================================================
Die eigene Adresse der Modi-App (10.09.2026)
crew.dogfather-universe.com
WARUM EINE EIGENE ADRESSE. Derselbe Grund, aus dem der Creator
Workspace am 06.09.2026 umgezogen ist (siehe workspace-umzug.js):
Auf einem Ursprung laesst sich genau EINE App installieren. Zwei
Manifeste mit verschachtelten Bereichen sind laut w3c/manifest
Nr. 1180 "strongly not recommended", und Chrome haelt sich daran --
der zweite Eintrag erscheint dann nur als Verknuepfung, nie als
eigene App. Filipe will fuer die Modis ausdruecklich eine eigene
App: "ich will dass es eine eigene app wird."
WAS SICH NICHT AENDERT: der Pfad /workspace/, der Dienst (4100), die
Datenbank, der Code. Getrennt wird ueber den HOSTNAMEN, nicht ueber
einen zweiten Prozess. Genau so machen es Hauptseite und Workspace
schon heute.
---------------------------------------------------------------------
DREI FALLEN, DIE MAN EINER BEDINGUNG NICHT ANSIEHT
---------------------------------------------------------------------
1. EIN MUSTER STATT EINER LISTE WAERE TOEDLICH.
/dogfather-universe\.com$/ passt auch auf
workspace.dogfather-universe.com -- und wuerde damit genau die
Adresse abschalten, die man schuetzen will. Deshalb hier zwei
feste Mengen und kein einziges Muster. (Dieselbe Ueberlegung
steht schon in workspace-umzug.js; sie hat dort einen Ausfall
verhindert und gilt hier unveraendert.)
2. "ALLES AUSSER crew." IST NICHT DASSELBE WIE "DIE ALTEN ADRESSEN".
Der naheliegende Satz waere: "Ein Modi darf ueberall rein, nur
nicht auf einer Adresse, die nicht crew heisst." Damit waeren
localhost, 127.0.0.1 und jede Pruefadresse eingeschlossen -- und
saemtliche Modi-Pruefungen wuerden ab sofort messen, dass ein
Modi nirgends hereinkommt, ohne dass irgendwo etwas rot wird.
Ein gruener Lauf, der nichts mehr prueft, ist schlimmer als ein
roter. Deshalb ist OHNE_MODI_HOSTS eine AUFZAEHLUNG der drei
echten Adressen. Wer eine vierte anlegt, traegt sie hier nach --
und pruef-crew-adresse.mjs sagt es ihm, wenn er es vergisst.
3. "FAENGT AN MIT /workspace" TRIFFT AUCH /workspaceXYZ.
Beim Workspace-Umzug waere das beinahe passiert. Hier gilt es
genauso, deshalb dieselbe Schreibweise: genau "/workspace" oder
alles unter "/workspace/", sonst nichts.
---------------------------------------------------------------------
WARUM DIE ALTE ADRESSE FUER MODIS ZUGEHT
---------------------------------------------------------------------
Ein Modi, der sich weiterhin auf workspace.dogfather-universe.com
anmelden koennte, haette zwei Anmeldungen, zwei Sitzungen und zwei
Apps auf dem Startbildschirm -- und der verborgene Zugang haette
damit eine zweite Spur genau dort, wo alle anderen arbeiten. Er
bekommt deshalb auf der alten Adresse KEINE Sitzung, sondern den
Hinweis auf die neue.
DASS DIESER HINWEIS DIE ADRESSE NENNT, IST KEIN LECK: Er erscheint
nur nach Eingabe eines gueltigen Modi-Codes. Wer den hat, ist Modi.
Fuer jeden anderen -- auch fuer jeden Manager mit gueltigem eigenen
Code -- veraendert sich an der alten Adresse ueberhaupt nichts.
---------------------------------------------------------------------
UND WARUM AUF DER NEUEN ADRESSE NUR MODIS HEREINKOMMEN
---------------------------------------------------------------------
Sonst waere crew. eine zweite, vollwertige Tuer in den Workspace --
und die erste Person, die sie zufaellig errät und ihren eigenen Code
eintippt, wuesste sofort, dass es hier noch etwas gibt. Ein fremder
Code bekommt dort deshalb dieselbe Antwort wie ein erfundener: die
Zugangswand sieht aus wie immer, und der Code stimmt nicht.
===================================================================== */
export const CREW_ADRESSE = "https://crew.dogfather-universe.com";
/** Die Adresse der Modi-App. Feste Menge, kein Muster -- Falle 1. */
const CREW_HOSTS = new Set(["crew.dogfather-universe.com"]);
/** Die echten Adressen, auf denen ein Modi nichts verloren hat.
* Aufzaehlung und ausdruecklich NICHT "alles ausser crew" -- Falle 2. */
const OHNE_MODI_HOSTS = new Set([
"dogfather-universe.com",
"www.dogfather-universe.com",
"workspace.dogfather-universe.com",
]);
/** Nur der Name, ohne Port und in Kleinschreibung.
* Hostnamen sind laut RFC 4343 gross/klein-unempfindlich; ein Browser
* darf "Workspace.Dogfather-Universe.com" schicken. */
function nurHost(hostKopfzeile) {
return String(hostKopfzeile || "").toLowerCase().split(":")[0];
}
/** Kommt die Anfrage ueber die Adresse der Modi-App? */
export function istCrewAdresse(hostKopfzeile) {
return CREW_HOSTS.has(nurHost(hostKopfzeile));
}
/** Ist das eine der Adressen, auf denen Modis nicht mehr arbeiten? */
export function istOhneModiAdresse(hostKopfzeile) {
return OHNE_MODI_HOSTS.has(nurHost(hostKopfzeile));
}
/** Gehoert dieser Pfad zum Workspace-Teil? Genau "/workspace" oder
* alles darunter -- aber NICHT /workspaceXYZ (Falle 3). */
export function istWorkspacePfad(pfad) {
const p = String(pfad || "");
return p === "/workspace" || p.startsWith("/workspace/");
}
/**
* Darf diese Person auf dieser Adresse angemeldet sein?
*
* Die eine Regel, aus der beide Richtungen folgen. Sie steht hier als
* eigene Funktion, damit sie ohne laufenden Server pruefbar ist -- und
* damit sie an GENAU EINER Stelle steht: sitzungLesen() in
* workspace.js. Jedes der 26 Fachmodule geht dort durch.
*
* @param {string} rolle Rolle der angemeldeten Person
* @param {string} hostKopfzeile Inhalt des Host-Kopffeldes
*/
export function sitzungPasstZurAdresse(rolle, hostKopfzeile) {
const modi = rolle === "modi";
if (istCrewAdresse(hostKopfzeile)) return modi; // crew: nur Modis
if (istOhneModiAdresse(hostKopfzeile)) return !modi; // echte alte Adressen: keine Modis
return true; // localhost, Pruefadressen: unberuehrt
}
/**
* Express-Zwischenschritt fuer die Adresse der Modi-App.
*
* Er macht zwei Dinge und ausdruecklich nichts mit Rechten -- die
* liegen in sitzungLesen():
*
* 1. NICHT INDIZIEREN. Die Adresse taucht heute in keinem oeffentlichen
* Zertifikatsprotokoll auf (nachgemessen am 10.09.2026: crt.sh
* kennt fuer dogfather-universe.com nur den Platzhalter-Eintrag).
* Eine Suchmaschine waere der zweite Weg, sie doch zu finden --
* der Kopf schliesst ihn. Er steht hier und nicht nur als
* <meta> im HTML, weil er dann auch fuer Bilder, Manifest und
* jede Antwort ohne HTML gilt.
*
* 2. NUR DER WORKSPACE. Auf crew. gibt es die Hauptseite nicht --
* sonst laege die komplette Dogfather-Website ein zweites Mal
* unter einer zweiten Adresse (doppelter Inhalt, und ein Weg,
* die Adresse ueber eine Suchmaschine zu finden). Alles ausserhalb
* von /workspace und /assets geht zur Zugangswand.
*
* Caddy tut Punkt 2 bereits mit derselben Weiche. Dass es hier ein
* zweites Mal steht, ist Absicht: Eine Schranke, die nur in einer
* Datei ausserhalb des Projekts existiert, faellt beim naechsten
* Serverumbau lautlos weg.
*/
export function crewWeiche(req, res, next) {
if (!istCrewAdresse(req.get("host"))) return next();
res.setHeader("X-Robots-Tag", "noindex, nofollow");
/* DAS MANIFEST MACHT DIE APP (10.09.2026).
Beide Adressen liefern dieselben HTML-Dateien aus -- in jeder steht
<link rel="manifest" href="/workspace/app.webmanifest">. Auf der
Modi-Adresse soll dahinter aber ein anderes Manifest stehen: eigener
Name ("Team Dogi"), eigenes Symbol. Sonst hiesse die App auf dem
Startbildschirm eines Modis "Creator Workspace" und traege das
Zeichen mit der Chili -- die zu Spicy Media gehoert, nicht zu ihnen.
WARUM DIE ADRESSE UMGEBOGEN WIRD, STATT IN 30 HTML-DATEIEN EINE
ZWEITE ZEILE ZU SETZEN: Eine zweite Zeile muesste jemand bei jeder
neuen Seite mitschreiben, und wer sie vergisst, bekommt eine Seite,
die still zur falschen App gehoert. Hier steht es einmal.
ZWISCHENSPEICHER: Cloudflare nimmt den Hostnamen in den Schluessel
auf, die beiden Antworten koennen sich also nicht vermischen. Der
Browser ohnehin nicht -- fuer ihn sind es zwei Ursprunge. */
if (req.path === "/workspace/app.webmanifest") {
req.url = "/workspace/crew.webmanifest";
return next();
}
if (istWorkspacePfad(req.path) || req.path.startsWith("/assets/")) return next();
return res.redirect(302, "/workspace/");
}
+15
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js"; import { gateMiddleware } from "./gate.js";
import { workspaceUmzug } from "./workspace-umzug.js"; import { workspaceUmzug } from "./workspace-umzug.js";
import { crewWeiche } from "./crew-adresse.js";
import { webdesignGate } from "./webdesign-gate.js"; import { webdesignGate } from "./webdesign-gate.js";
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
import { workspaceRouter, sichtSetzen } from "./workspace.js"; import { workspaceRouter, sichtSetzen } from "./workspace.js";
@@ -112,6 +113,20 @@ app.use((req, res, next) => {
unter der alten Adresse nicht mehr erscheint. */ unter der alten Adresse nicht mehr erscheint. */
app.use(workspaceUmzug); app.use(workspaceUmzug);
/* Die eigene Adresse der Modi-App (10.09.2026): crew.dogfather-universe.com.
Begruendung und die drei Fallstricke stehen in crew-adresse.js -- sie sind
dort als eigene Funktionen pruefbar (pruef-crew-adresse.mjs).
Steht direkt neben dem Workspace-Umzug und aus demselben Grund FRUEH: Der
Kopf "nicht indizieren" soll auch fuer Manifest, Symbole und die
Zugangswand gelten, nicht erst ab der ersten Schranke.
WICHTIG: Diese Weiche vergibt KEINE Rechte und nimmt keine. Wer auf
welcher Adresse angemeldet sein darf, entscheidet sitzungLesen() in
workspace.js -- an genau einer Stelle, durch die alle 26 Fachmodule
gehen. Zwei Orte fuer dieselbe Regel waeren einer zu viel. */
app.use(crewWeiche);
/* Inhaltsrichtlinie (26.08.2026). Steht bewusst HIER -- vor jeder /* Inhaltsrichtlinie (26.08.2026). Steht bewusst HIER -- vor jeder
Schranke und vor express.static: Auch die Zugangswand und die Schranke und vor express.static: Auch die Zugangswand und die
404-Seite sollen sie bekommen, denn gerade dort werden Eingaben 404-Seite sollen sie bekommen, denn gerade dort werden Eingaben
+335
View File
@@ -0,0 +1,335 @@
/* =====================================================================
DIE EIGENE ADRESSE DER MODI-APP
Geprueft wird nicht "die neue Adresse geht", sondern die vier Fragen,
an denen dieser Umbau scheitern kann:
1. KOMMT EIN MODI AUF crew. HEREIN -- und sonst niemand?
2. IST DER WORKSPACE UNVERAENDERT? Derselbe Dienst bedient beide
Adressen. Wer nicht auf den Hostnamen prueft, sperrt mit einer
einzigen Zeile alle Creator, Scouts und Manager aus, sofort nach
dem Neustart und ohne Vorwarnung.
3. BLEIBEN DIE PRUEFADRESSEN UNBERUEHRT? Waere die Regel "alles
ausser crew", dann wuerden ab sofort sieben andere Pruefdateien
messen, dass ein Modi nirgends hereinkommt -- gruen, weil sie
nichts mehr finden. Ein Lauf, der nichts mehr prueft, ist
schlimmer als ein roter.
4. HEISST DIE APP AUF DEM HANDY DES MODIS ANDERS? Sonst war der
ganze Umzug umsonst.
MIT GEGENPROBE. Ganz unten bekommt der Modi in der Datenbank eine
andere Rolle -- danach MUSS ihn dieselbe Sitzung auf crew. nicht mehr
hereinlassen. Ohne diesen Teil waere nicht bewiesen, dass die
Pruefung ueberhaupt anschlagen kann.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request } from "node:http";
const ordner = mkdtempSync(join(tmpdir(), "ws-crew-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = "4371";
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-crew-adresse");
await new Promise((r) => setTimeout(r, 700));
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); };
/* ---------------------------------------------------------------------
Ein Aufruf mit frei waehlbarem Host-Kopf.
Bewusst node:http und nicht fetch: `Host` gilt in der Fetch-Norm als
verbotener Kopf, und ob undici das durchlaesst, ist eine Annahme, auf
die diese ganze Pruefung nicht gebaut sein soll. Hier wird der Kopf
nachweislich gesetzt.
------------------------------------------------------------------ */
function hole(pfad, { host, methode = "GET", rumpf = null, keks = "" } = {}) {
return new Promise((fertig, schief) => {
const kopf = { Host: host };
if (rumpf) { kopf["Content-Type"] = "application/json"; kopf["Content-Length"] = Buffer.byteLength(rumpf); }
if (keks) kopf.Cookie = keks;
const a = request({ host: "127.0.0.1", port: 4371, path: pfad, method: methode, headers: kopf },
(antwort) => {
let text = "";
antwort.setEncoding("utf8");
antwort.on("data", (t) => { text += t; });
antwort.on("end", () => fertig({
status: antwort.statusCode,
kopf: antwort.headers,
text,
keks: (antwort.headers["set-cookie"] || [])
.map((z) => z.split(";")[0]).join("; "),
}));
});
a.on("error", schief);
a.setTimeout(15_000, () => { a.destroy(new Error("Zeitueberschreitung")); });
if (rumpf) a.write(rumpf);
a.end();
});
}
const anmelden = (host, rolle, code) =>
hole("/workspace/api/anmelden", { host, methode: "POST", rumpf: JSON.stringify({ rolle, code }) });
/* ---------- Personen anlegen ---------------------------------------- */
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 schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const kennung = (code) => createHmac("sha256", schluessel).update(code).digest("hex");
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, kennung(code), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Schulle", "manager", "CODE-MAN-0001");
const idModi = anlegen("Marina", "modi", "CODE-MODI-0001");
const CREW = "crew.dogfather-universe.com";
const WS = "workspace.dogfather-universe.com";
const HAUPT = "dogfather-universe.com";
const LOKAL = "127.0.0.1:4371";
/* =====================================================================
TEIL A -- DIE REGELN, OHNE SERVER
Sie stehen als eigene Funktionen in crew-adresse.js, damit genau das
hier moeglich ist: die Entscheidung pruefen, nicht ihre Auswirkung.
===================================================================== */
console.log("\n--- Die Regeln selbst ---");
const { istCrewAdresse, istOhneModiAdresse, istWorkspacePfad, sitzungPasstZurAdresse, CREW_ADRESSE }
= await import("./crew-adresse.js");
ok(istCrewAdresse(CREW) === true, "crew.dogfather-universe.com ist die Modi-Adresse");
ok(istCrewAdresse("CREW.Dogfather-Universe.COM") === true, "Gross/klein macht keinen Unterschied");
ok(istCrewAdresse(CREW + ":443") === true, "ein Port angehaengt macht keinen Unterschied");
ok(istCrewAdresse(WS) === false, "workspace. ist NICHT die Modi-Adresse");
ok(istCrewAdresse(HAUPT) === false, "die Hauptseite ist NICHT die Modi-Adresse");
ok(istCrewAdresse("crew.dogfather-universe.com.boese.de") === false,
"eine fremde Domain mit dem Namen darin zaehlt nicht");
ok(istCrewAdresse("") === false && istCrewAdresse(null) === false,
"leer und fehlend sind nicht die Modi-Adresse");
ok(istOhneModiAdresse(WS) === true, "auf workspace. haben Modis nichts verloren");
ok(istOhneModiAdresse(HAUPT) === true, "auf der Hauptseite ebenfalls nicht");
ok(istOhneModiAdresse("www." + HAUPT) === true, "auf www. ebenfalls nicht");
ok(istOhneModiAdresse(CREW) === false, "crew. gehoert NICHT dazu (Falle 1)");
ok(istOhneModiAdresse(LOKAL) === false, "127.0.0.1 gehoert NICHT dazu (Falle 2)");
ok(istOhneModiAdresse("localhost:4100") === false, "localhost gehoert NICHT dazu (Falle 2)");
/* Die beiden Mengen duerfen sich nirgends beruehren. Ein Muster statt
einer Liste haette hier sofort angeschlagen. */
const alleAdressen = [CREW, WS, HAUPT, "www." + HAUPT, LOKAL, "localhost:4100", "git." + HAUPT];
ok(alleAdressen.every((h) => !(istCrewAdresse(h) && istOhneModiAdresse(h))),
"keine Adresse liegt in beiden Mengen");
ok(istWorkspacePfad("/workspace") === true, "/workspace gehoert dazu");
ok(istWorkspacePfad("/workspace/start.html") === true, "/workspace/start.html gehoert dazu");
ok(istWorkspacePfad("/workspaceXYZ") === false, "/workspaceXYZ gehoert NICHT dazu (Falle 3)");
ok(istWorkspacePfad("/workspace-alt") === false, "/workspace-alt gehoert NICHT dazu (Falle 3)");
ok(istWorkspacePfad("/assets/img/x.png") === false, "/assets liegt ausserhalb");
const ROLLEN = ["admin", "spicy", "manager", "scout", "creator", "modi"];
let passtWahr = 0, passtFalsch = 0;
for (const r of ROLLEN) {
const aufCrew = sitzungPasstZurAdresse(r, CREW);
const aufWs = sitzungPasstZurAdresse(r, WS);
const aufLokal = sitzungPasstZurAdresse(r, LOKAL);
ok(aufCrew === (r === "modi"), `crew.: ${r} ${r === "modi" ? "darf" : "darf nicht"}`);
ok(aufWs === (r !== "modi"), `workspace.: ${r} ${r !== "modi" ? "darf" : "darf nicht"}`);
ok(aufLokal === true, `127.0.0.1: ${r} unveraendert erlaubt`);
for (const w of [aufCrew, aufWs, aufLokal]) { if (w) passtWahr++; else passtFalsch++; }
}
/* Die Gegenprobe zur Regel selbst: Eine Funktion, die immer `true`
liefert, haette alle 18 Zeilen darueber ebenfalls teilweise bestanden.
Beide Antworten muessen vorkommen. */
ok(passtWahr > 0 && passtFalsch > 0,
`die Regel unterscheidet wirklich (${passtWahr}x ja, ${passtFalsch}x nein)`);
/* =====================================================================
TEIL B -- AM LAUFENDEN SERVER
===================================================================== */
console.log("\n--- Anmeldung je Adresse ---");
const modiAufCrew = await anmelden(CREW, "modi", "CODE-MODI-0001");
ok(modiAufCrew.status === 200, "crew.: der Modi kommt herein");
let zielModi = {};
try { zielModi = JSON.parse(modiAufCrew.text); } catch { /* bleibt leer */ }
ok(zielModi.weiter === "/workspace/start.html", "crew.: er landet auf seiner Startseite");
ok(modiAufCrew.keks.includes("dfw_sitzung"), "crew.: er bekommt eine Sitzung");
/* Der Code des Modis wird bewusst mit der Kachel 'creator' geschickt --
genau so kommt er auch aus der Zugangswand, denn eine Modi-Kachel
gibt es nicht. */
const modiOhneKachel = await anmelden(CREW, "creator", "CODE-MODI-0001");
ok(modiOhneKachel.status === 200, "crew.: die gewaehlte Kachel ist egal (stiller Zugang)");
const managerAufCrew = await anmelden(CREW, "manager", "CODE-MAN-0001");
ok(managerAufCrew.status === 401, "crew.: ein Manager mit gueltigem Code kommt NICHT herein");
ok(/ungueltig/.test(managerAufCrew.text),
"crew.: er bekommt dieselbe Antwort wie bei einem Tippfehler");
ok(!managerAufCrew.keks.includes("dfw_sitzung"), "crew.: und keine Sitzung");
const adminAufCrew = await anmelden(CREW, "admin", "CODE-DOGI-0001");
ok(adminAufCrew.status === 401, "crew.: auch DogFather selbst kommt dort nicht herein");
const modiAufWs = await anmelden(WS, "creator", "CODE-MODI-0001");
ok(modiAufWs.status === 200, "workspace.: der Modi bekommt eine Antwort");
let zielWs = {};
try { zielWs = JSON.parse(modiAufWs.text); } catch { /* bleibt leer */ }
ok(zielWs.weiter === `${CREW_ADRESSE}/workspace/start.html`,
"workspace.: sie zeigt ihm die neue Adresse");
ok(!modiAufWs.keks.includes("dfw_sitzung"),
"workspace.: aber KEINE Sitzung -- er meldet sich nur noch auf crew. an");
/* DER WICHTIGSTE PUNKT DIESER DATEI. Derselbe Dienst bedient beide
Adressen; ein Denkfehler hier sperrt das ganze Team aus. */
const managerAufWs = await anmelden(WS, "manager", "CODE-MAN-0001");
ok(managerAufWs.status === 200 && managerAufWs.keks.includes("dfw_sitzung"),
"workspace.: der Manager kommt herein wie immer -- der Workspace ist unberuehrt");
const managerLokal = await anmelden(LOKAL, "manager", "CODE-MAN-0001");
const modiLokal = await anmelden(LOKAL, "creator", "CODE-MODI-0001");
ok(managerLokal.status === 200 && managerLokal.keks.includes("dfw_sitzung"),
"127.0.0.1: der Manager kommt herein (Pruefadressen unberuehrt)");
ok(modiLokal.status === 200 && modiLokal.keks.includes("dfw_sitzung"),
"127.0.0.1: der Modi ebenfalls -- die sieben anderen Modi-Pruefungen messen weiter");
/* ---------------------------------------------------------------------
Eine Sitzung gehoert zu genau einer Adresse.
------------------------------------------------------------------ */
console.log("\n--- Eine Sitzung, eine Adresse ---");
const keksModi = modiAufCrew.keks;
const keksManager = managerAufWs.keks;
const modiDaheim = await hole("/workspace/api/ich", { host: CREW, keks: keksModi });
ok(modiDaheim.status === 200, "crew.: der angemeldete Modi wird erkannt");
ok(/Marina/.test(modiDaheim.text), "crew.: und zwar als er selbst");
const modiFremd = await hole("/workspace/api/ich", { host: WS, keks: keksModi });
ok(modiFremd.status === 401, "workspace.: dieselbe Sitzung zaehlt dort nicht");
const managerDaheim = await hole("/workspace/api/ich", { host: WS, keks: keksManager });
ok(managerDaheim.status === 200, "workspace.: der Manager wird erkannt");
const managerFremd = await hole("/workspace/api/ich", { host: CREW, keks: keksManager });
ok(managerFremd.status === 401, "crew.: seine Sitzung zaehlt dort nicht");
/* Auch die Seitenschranke, nicht nur die Schnittstelle: Eine Seite, die
ausgeliefert wird, waere ein Loch, selbst wenn die Daten dahinter
leer blieben. */
const seiteFremd = await hole("/workspace/start.html", { host: CREW, keks: keksManager });
ok(seiteFremd.status === 302, "crew.: die Startseite schickt den Manager zur Zugangswand");
const seiteDaheim = await hole("/workspace/start.html", { host: CREW, keks: keksModi });
ok(seiteDaheim.status === 200, "crew.: dem Modi liefert sie die Seite");
/* ---------------------------------------------------------------------
Die Weiche: was es auf crew. ueberhaupt gibt.
------------------------------------------------------------------ */
console.log("\n--- Die Weiche ---");
const wurzel = await hole("/", { host: CREW });
ok(wurzel.status === 302 && wurzel.kopf.location === "/workspace/",
"crew.: die Hauptseite gibt es dort nicht, es geht zur Zugangswand");
const wurzelWs = await hole("/", { host: HAUPT });
ok(wurzelWs.status === 200, "die Hauptseite selbst ist unveraendert erreichbar");
const fastWorkspace = await hole("/workspaceXYZ", { host: CREW });
ok(fastWorkspace.status === 302, "crew.: /workspaceXYZ gehoert nicht dazu (Falle 3)");
const robots = await hole("/workspace/", { host: CREW });
ok(String(robots.kopf["x-robots-tag"] || "").includes("noindex"),
"crew.: nicht indizieren -- die Adresse soll in keiner Suchmaschine stehen");
const robotsWs = await hole("/workspace/", { host: WS });
ok(!robotsWs.kopf["x-robots-tag"],
"workspace.: dort steht der Kopf NICHT (Gegenprobe zur Weiche)");
/* ---------------------------------------------------------------------
Die App heisst anders.
------------------------------------------------------------------ */
console.log("\n--- Zwei Apps, zwei Namen ---");
const manifestCrew = await hole("/workspace/app.webmanifest", { host: CREW });
const manifestWs = await hole("/workspace/app.webmanifest", { host: WS });
let mc = {}, mw = {};
try { mc = JSON.parse(manifestCrew.text); } catch { /* bleibt leer */ }
try { mw = JSON.parse(manifestWs.text); } catch { /* bleibt leer */ }
ok(manifestCrew.status === 200 && manifestWs.status === 200,
"beide Adressen liefern ein Manifest");
ok(mc.name === "Team Dogi", `crew.: die App heisst "Team Dogi" (gelesen: ${mc.name || "?"})`);
ok(mw.name === "Creator Workspace — Spicy & Dogi",
`workspace.: dort heisst sie unveraendert (gelesen: ${mw.name || "?"})`);
ok(Array.isArray(mc.icons) && mc.icons.length > 0
&& mc.icons.every((s) => /crew-/.test(s.src)),
`crew.: alle ${(mc.icons || []).length} Symbole sind die eigenen`);
ok(Array.isArray(mw.icons) && mw.icons.length > 0
&& mw.icons.every((s) => /workspace-/.test(s.src)),
"workspace.: dort weiterhin die alten");
ok(mc.start_url === "/workspace/start.html" && mc.scope === "/workspace/",
"crew.: Bereich und Startseite stimmen");
/* Die Symboldateien muessen es auch wirklich geben -- ein Manifest, das
auf nichts zeigt, faellt sonst erst auf dem Startbildschirm auf. */
let symboleDa = 0;
for (const s of mc.icons || []) {
const pfad = String(s.src).split("?")[0];
const a = await hole(pfad, { host: CREW });
if (a.status === 200) symboleDa++;
}
ok((mc.icons || []).length > 0 && symboleDa === (mc.icons || []).length,
`crew.: alle ${symboleDa} Symbole werden auch ausgeliefert`);
/* =====================================================================
DIE GEGENPROBE
Bis hierhin koennte alles auch dann gruen sein, wenn die Regel gar
nicht auf die Rolle sieht. Deshalb wird sie jetzt ausgehebelt: Der
Modi bekommt in der Datenbank die Rolle 'manager'. Seine Sitzung
bleibt dieselbe -- aber auf crew. darf sie ab jetzt NICHT mehr
gelten.
===================================================================== */
console.log("\n--- Gegenprobe: die Regel ausgehebelt ---");
d.prepare("UPDATE personen SET rolle = 'manager' WHERE id = ?").run(idModi);
const nachher = await hole("/workspace/api/ich", { host: CREW, keks: keksModi });
ok(nachher.status === 401,
"aus dem Modi wurde ein Manager -- seine Sitzung gilt auf crew. nicht mehr");
const nachherWs = await hole("/workspace/api/ich", { host: WS, keks: keksModi });
ok(nachherWs.status === 200,
"und dieselbe Sitzung gilt jetzt auf workspace. -- die Regel sieht wirklich auf die Rolle");
d.prepare("UPDATE personen SET rolle = 'modi' WHERE id = ?").run(idModi);
const zurueck = await hole("/workspace/api/ich", { host: CREW, keks: keksModi });
ok(zurueck.status === 200, "zurueckgedreht gilt sie wieder");
/* ---------- Ende ------------------------------------------------------ */
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt die Datei manchmal */ }
console.log(`\n${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}`
+ ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
process.exit(fehler === 0 ? 0 : 1);
+265 -126
View File
@@ -1,22 +1,24 @@
/* ===================================================================== /* =====================================================================
DIE CHECKLISTEN EINES MODIS DIE MODIS MELDEN -- und DogFather und VanVan antworten
Wunsch Filipe (10.09.2026): *"diese kategorien mussen auf dieser Wunsch Filipe (10.09.2026): *"diese 3 kategorien müssen so
seite auch noch auf die modis perfektionniert werden, da mussen eingestellt werden dass die modis mir quasi aufgaben verteilen, also
lauter sachen sein die vanvan und ich danach verteilen können und da das sie drücken können passt so oder verbesserungen. weil die modis
anpassen können ob gut oder nicht."* sind ja da um mir zu helfen."* Und dazu: *"Erst ein Vorschlag, den du
annimmst."*
VIER FRAGEN: DIE RICHTUNG WAR VORHER UMGEKEHRT, und diese Pruefung auch: Sie hat
geprueft, dass DogFather den Modi bewertet. Das war eine Note fuer
ihn statt einer Hilfe fuer DogFather -- und es widersprach der
Hausregel, nach der niemand ueber dem Team steht.
1. Bekommt ein Modi SEINEN Satz -- nicht den der Creator? FUENF FRAGEN:
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() 1. Beurteilt der Modi die SENDUNG -- und darf er druecken?
pauschal ja -- und darin steckt auch Spicy Media. Wer die 2. Duerfen DogFather und VanVan seinen Bericht NICHT ueberschreiben?
Modi-Auswahl daran haengt, oeffnet sie ihr nebenbei mit, ohne dass an 3. Landet ein "Verbessern" im Eingang der beiden?
der Stelle etwas davon steht. 4. Wird daraus eine Aufgabe -- und merkt der Modi, dass es ankam?
5. Bekommt Spicy Media von alledem nichts?
===================================================================== */ ===================================================================== */
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -69,7 +71,7 @@ const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001");
const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001"); const idCreat = anlegen("Tili", "creator", "CODE-ANNA-0001");
const idModi = anlegen("Marina", "modi", "CODE-MODI-0001"); const idModi = anlegen("Marina", "modi", "CODE-MODI-0001");
d.close(); d.close();
void idDogi; void idSpicy;
async function anmelden(rolle, code) { async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", { const a = await fetch(BASIS + "/workspace/api/anmelden", {
@@ -93,144 +95,281 @@ const vanAn = await anmelden("admin", "CODE-VAN-0001");
const spicyAn = await anmelden("spicy", "CODE-SPIC-0001"); const spicyAn = await anmelden("spicy", "CODE-SPIC-0001");
const creatAn = await anmelden("creator", "CODE-ANNA-0001"); const creatAn = await anmelden("creator", "CODE-ANNA-0001");
/* ---------- 1. Sein eigener Satz ------------------------------------- */ /* ---------- 1. Er beurteilt die Sendung ------------------------------ */
console.log("\n=== 1. Der Modi bekommt seinen eigenen Punktesatz ===\n"); console.log("\n=== 1. Der Modi beurteilt die Sendung ===\n");
const BEREICHE = ["live", "community", "technik"]; const BEREICHE = ["live", "community", "technik"];
let ersterPunkt = null; let ersterPunkt = null;
for (const b of BEREICHE) { for (const b of BEREICHE) {
const a = await hole(modiAn.keks, `/workspace/api/checkliste/${b}`); const a = await hole(modiAn.keks, `/workspace/api/checkliste/${b}`);
if (a.status !== 200) { unklar(`${b}: nicht lesbar (${a.status})`); continue; } if (a.status !== 200) { unklar(`${b}: nicht lesbar (${a.status})`); continue; }
const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []); 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-")), ok(punkte.length > 0 && punkte.every((p) => p.schluessel.startsWith("m-")),
`${b.padEnd(10)} ${punkte.length} Punkte, alle aus seinem Satz`); `${b.padEnd(10)} ${punkte.length} Punkte aus seinem Satz`);
if (b === "live" && punkte.length) ersterPunkt = punkte[0].schluessel; 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`);
ok(a.daten?.creator_id === idModi, /* DAS IST DIE UMKEHRUNG: Er darf druecken. Vorher stand hier das
`${b.padEnd(10)} es ist seine eigene Liste (${a.daten?.creator_id})`); Gegenteil, und das war der Fehler. */
ok(a.daten?.darf_bewerten === false, ok(a.daten?.darf_bewerten === true,
`${b.padEnd(10)} bewerten darf er sich nicht selbst`); `${b.padEnd(10)} und er darf "Passt so" oder "Verbessern" drücken`);
} }
/* GEGENPROBE: Der Creator bekommt unveraendert seinen Satz -- und darf
/* GEGENPROBE: Ein Creator bekommt weiterhin SEINEN Satz -- sonst waere sich weiterhin NICHT selbst bewerten. Sonst waere oben auch gruen,
oben auch gruen, wenn ich allen denselben gegeben haette. */ wenn ich allen alles erlaubt haette. */
{ {
const a = await hole(creatAn.keks, "/workspace/api/checkliste/live"); const a = await hole(creatAn.keks, "/workspace/api/checkliste/live");
const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []); const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []);
ok(punkte.length > 0 && punkte.every((p) => !p.schluessel.startsWith("m-")), ok(punkte.length > 0 && punkte.every((p) => !p.schluessel.startsWith("m-")),
`der Creator bekommt unveraendert seinen Satz (${punkte.length} Punkte)`); `der Creator bekommt unverändert seinen Satz (${punkte.length} Punkte)`);
ok(a.daten?.darf_bewerten === false, "und darf sich weiterhin nicht selbst bewerten");
} }
/* ---------- 2. DogFather und VanVan waehlen ihn aus ------------------ */ /* ---------- 2. Sein Bericht gehört ihm ------------------------------- */
console.log("\n=== 2. DogFather und VanVan sehen ihn ===\n"); console.log("\n=== 2. Sein Bericht gehört ihm ===\n");
for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) { for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) {
const a = await hole(an.keks, "/workspace/api/checkliste/live"); const a = await hole(an.keks, `/workspace/api/checkliste/live?creator_id=${idModi}`);
const drin = (a.daten?.creatoren || []).some((z) => z.id === idModi); const punkte = (a.daten?.gruppen || []).flatMap((g) => g.punkte || []);
ok(drin, `${name.padEnd(10)} hat ihn in der Auswahl`); ok(punkte.length > 0, `${name.padEnd(10)} sieht seinen Bericht (${punkte.length} Punkte)`);
const b = await hole(an.keks, `/workspace/api/checkliste/live?creator_id=${idModi}`); /* NICHT ueberschreiben. Duerften sie es, waere es kein Bericht mehr,
const punkte = (b.daten?.gruppen || []).flatMap((g) => g.punkte || []); sondern eine Abstimmung darueber, was er gesehen haben soll. */
ok(punkte.length > 0 && punkte.every((p) => p.schluessel.startsWith("m-")), ok(a.daten?.darf_bewerten === false,
`${name.padEnd(10)} bekommt SEINEN Satz, nicht den der Creator (${punkte.length})`); `${name.padEnd(10)} kann ihn aber nicht überschreiben`);
ok(b.daten?.darf_bewerten === true, `${name.padEnd(10)} darf bewerten`); const b = await hole(an.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, {
}
/* ---------- 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, method: "PUT", headers: jsonKopf,
body: JSON.stringify({ stufe: "gut", creator_id: idModi }), body: JSON.stringify({ stufe: "gut", creator_id: idModi }),
}); });
ok(c.status === 404, `bewerten erst recht nicht (${c.status})`); ok(b.status === 403 && /Team-Lage/.test(b.daten?.fehler || ""),
`${name.padEnd(10)} und wird auf die Team-Lage verwiesen (${b.status})`);
} }
/* ---------- 3. Er meldet etwas --------------------------------------- */
/* ---------- 5. Die gemeinsame Lage ----------------------------------- */ console.log("\n=== 3. Er drückt „Verbessern“ ===\n");
console.log("\n=== 5. Die Seite für DogFather und VanVan ===\n");
{ {
const a = await hole(modiAn.keks, `/workspace/api/checkliste/live/${ersterPunkt}`, {
method: "PUT", headers: jsonKopf,
body: JSON.stringify({ stufe: "verbessern", begruendung: "Kam erst zehn Minuten vorher" }),
});
ok(a.status === 200, `die Meldung geht durch (${a.status})`);
for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) { for (const [name, an] of [["DogFather", dogiAn], ["VanVan", vanAn]]) {
const a = await hole(an.keks, "/workspace/api/teamlage"); const b = await hole(an.keks, "/workspace/api/teamlage");
if (a.status !== 200) { unklar(`${name}: nicht lesbar (${a.status})`); continue; } const drin = (b.daten?.eingang || []).find((e) => e.schluessel === ersterPunkt);
const eintrag = (a.daten?.modis || []).find((m) => m.id === idModi); ok(!!drin, `${name.padEnd(10)} hat sie im Eingang`);
ok(!!eintrag, `${name.padEnd(10)} sieht den Modi in der Lage`); ok(drin?.begruendung === "Kam erst zehn Minuten vorher",
/* Die Zahlen muessen zum Punktesatz passen -- eine Uebersicht, die `${name.padEnd(10)} mit seinem Satz dabei ("${drin?.begruendung || ""}")`);
andere Zahlen zeigt als die Seite dahinter, ist schlimmer als ok(!!drin?.titel && drin.titel !== ersterPunkt,
keine. */ `${name.padEnd(10)} und dem Titel, nicht dem Schlüssel ("${drin?.titel || ""}")`);
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`);
} }
/* Und "Passt so" landet NICHT im Eingang -- sonst waere er nach einer
Sendung voll mit Lob, und das Wichtige ginge unter. */
const zweiter = await hole(modiAn.keks, "/workspace/api/checkliste/live");
const p2 = (zweiter.daten?.gruppen || []).flatMap((g) => g.punkte || [])[1]?.schluessel;
await hole(modiAn.keks, `/workspace/api/checkliste/live/${p2}`, {
method: "PUT", headers: jsonKopf, body: JSON.stringify({ stufe: "gut" }),
});
const c = await hole(dogiAn.keks, "/workspace/api/teamlage");
ok(!(c.daten?.eingang || []).some((e) => e.schluessel === p2),
"„Passt so“ landet nicht im Eingang");
} }
/* ---------- 6. Für alle anderen gibt es sie nicht -------------------- */ /* ---------- 4. Daraus wird eine Aufgabe ------------------------------ */
console.log("\n=== 6. Und für alle anderen gibt es sie nicht ===\n"); console.log("\n=== 4. Daraus wird eine Aufgabe ===\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"); const a = await hole(dogiAn.keks, "/workspace/api/teamlage/behandeln", {
ok((a.daten?.bereiche_zusatz || []).length === 1, method: "POST", headers: jsonKopf,
`bei DogFather ist genau eine Zusatzkachel da (${(a.daten?.bereiche_zusatz || []).length})`); body: JSON.stringify({
bereich: "live", schluessel: ersterPunkt, person_id: idModi, art: "aufgabe",
}),
});
ok(a.status === 200 && a.daten?.aufgabe_id, `angenommen (${a.status})`);
const b = await hole(dogiAn.keks, "/workspace/api/aufgaben");
const neu = (b.daten?.aufgaben || []).find((x) => x.id === a.daten?.aufgabe_id);
ok(neu?.verantwortlich_id === idDogi, "die Aufgabe gehört dem, der gedrückt hat");
ok(/zehn Minuten vorher/.test(neu?.beschreibung || ""),
"der Satz des Modis steht in der Aufgabe");
/* Aus dem Eingang verschwunden -- ein Eingang, der nie leer wird,
wird nicht mehr angesehen. */
const c = await hole(dogiAn.keks, "/workspace/api/teamlage");
ok(!(c.daten?.eingang || []).some((e) => e.schluessel === ersterPunkt),
"und ist aus dem Eingang verschwunden");
/* UND DER MODI MERKT ES. Eine Meldung, auf die nichts folgt, sieht
fuer ihn aus wie eine, die niemand gelesen hat. */
const e = await hole(modiAn.keks,
`/workspace/api/checkliste/live/${ersterPunkt}/nachrichten`);
const texte = (e.daten?.nachrichten || e.daten || []).map((z) => z.text || "");
ok(texte.some((t) => /Aufgabe geworden/.test(t)),
`er sieht die Antwort an seinem Punkt (${texte.length} Nachricht(en))`);
/* Ein zweites Mal geht nicht -- sonst entstuenden zwei Aufgaben aus
einer Meldung. */
const f = await hole(dogiAn.keks, "/workspace/api/teamlage/behandeln", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({
bereich: "live", schluessel: ersterPunkt, person_id: idModi, art: "aufgabe",
}),
});
ok(f.status === 404, `ein zweites Mal geht nicht (${f.status})`);
}
/* ---------- 5. Oder eine begründete Absage --------------------------- */
console.log("\n=== 5. Oder eine begründete Absage ===\n");
{
const liste = await hole(modiAn.keks, "/workspace/api/checkliste/community");
const punkt = (liste.daten?.gruppen || []).flatMap((g) => g.punkte || [])[0]?.schluessel;
await hole(modiAn.keks, `/workspace/api/checkliste/community/${punkt}`, {
method: "PUT", headers: jsonKopf,
body: JSON.stringify({ stufe: "verbessern", begruendung: "Regeln standen weit unten" }),
});
/* OHNE GRUND KEINE ABSAGE. Wer nichts dazu schreibt, laesst den Modi
genauso ratlos zurueck wie Schweigen -- nur ist die Meldung dann
auch noch aus dem Eingang weg. */
const ohne = await hole(vanAn.keks, "/workspace/api/teamlage/behandeln", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({
bereich: "community", schluessel: punkt, person_id: idModi, art: "abgelehnt",
}),
});
ok(ohne.status === 400, `eine Absage ohne Grund wird abgelehnt (${ohne.status})`);
const mit = await hole(vanAn.keks, "/workspace/api/teamlage/behandeln", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({
bereich: "community", schluessel: punkt, person_id: idModi,
art: "abgelehnt", grund: "Steht schon oben, wir lassen es so",
}),
});
ok(mit.status === 200 && !mit.daten?.aufgabe_id,
`mit Grund geht sie durch und legt keine Aufgabe an (${mit.status})`);
const e = await hole(modiAn.keks,
`/workspace/api/checkliste/community/${punkt}/nachrichten`);
const texte = (e.daten?.nachrichten || e.daten || []).map((z) => z.text || "");
ok(texte.some((t) => /lassen es so/.test(t)), "der Grund kommt beim Modi an");
}
/* ---------- 6. Spicy Media bekommt nichts davon ---------------------- */
console.log("\n=== 6. Spicy Media bekommt nichts davon ===\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 || []).map((z) => z.name).join(", ")})`);
ok((a.daten?.creatoren || []).some((z) => z.id === idCreat), "den Creator dagegen schon");
const b = await hole(spicyAn.keks, `/workspace/api/checkliste/live?creator_id=${idModi}`);
ok(b.status === 404, `und über die Adresse ebenfalls nicht (${b.status})`);
const c = await hole(spicyAn.keks, "/workspace/api/teamlage");
ok(c.status === 404, `die Team-Lage bekommt sie nicht (${c.status})`);
const e = await hole(spicyAn.keks, "/workspace/api/teamlage/behandeln", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ bereich: "live", schluessel: ersterPunkt, person_id: idModi, art: "aufgabe" }),
});
ok(e.status === 404, `und behandeln erst recht nicht (${e.status})`);
}
/* ---------- 7. Die Kachel -------------------------------------------- */
console.log("\n=== 7. Die Kachel für euch beide ===\n");
for (const [name, an, soll] of [
["DogFather", dogiAn, 1], ["VanVan", vanAn, 1],
["Spicy Media", spicyAn, 0], ["Creator", creatAn, 0], ["Modi", modiAn, 0],
]) {
const a = await hole(an.keks, "/workspace/api/ich");
const n = (a.daten?.bereiche_zusatz || []).length;
ok(n === soll, `${name.padEnd(12)} bekommt ${n} Zusatzkachel(n), erwartet ${soll}`);
}
/* ---------- 8. Angebote ---------------------------------------------- */
console.log("\n=== 8. Ein Angebot an DogFather ===\n");
{
/* Der Modi schlaegt etwas vor -- mit Nutzen, Aufwand und dem, was
DogFather dafuer tun muesste. */
const a = await hole(modiAn.keks, "/workspace/api/bereich/angebote", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({
art: "aktion", titel: "Zuschauer-Challenge am Freitag",
text: "Drei Runden, Gewinner bekommt einen Shoutout.",
einsatz: "Im Live ansagen und die Regeln vorlesen",
bewertung: 4, dringlichkeit: "niedrig",
}),
});
ok(a.status === 201, `der Modi legt ein Angebot an (${a.status})`);
/* Es steht im Eingang von DogFather und VanVan -- mit dem, was sein
Teil waere. Ohne das muesste er entscheiden, ohne ihn zu kennen. */
const b = await hole(dogiAn.keks, "/workspace/api/teamlage");
const ang = (b.daten?.angebote || [])[0];
ok(!!ang, `es steht in der Team-Lage (${(b.daten?.angebote || []).length})`);
ok(ang?.einsatz === "Im Live ansagen und die Regeln vorlesen",
`mit seinem Teil dabei ("${ang?.einsatz || ""}")`);
ok(ang?.bewertung === 4 && ang?.dringlichkeit === "niedrig",
`mit Nutzen ${ang?.bewertung} und Aufwand ${ang?.dringlichkeit}`);
/* Und Spicy Media sieht davon nichts. */
const c = await hole(spicyAn.keks, "/workspace/api/bereich/angebote");
ok(c.status === 404, `Spicy Media kommt nicht an die Angebote (${c.status})`);
/* ANNEHMEN: zwei Aufgaben -- der Modi setzt um, DogFather bekommt
seinen Teil. Genau das war Filipes Wunsch. */
const e = await hole(dogiAn.keks, "/workspace/api/teamlage/angebot", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: ang.id, art: "angenommen" }),
});
ok(e.status === 200 && e.daten?.aufgaben === 2,
`angenommen, ${e.daten?.aufgaben} Aufgaben angelegt`);
const f = await hole(dogiAn.keks, "/workspace/api/aufgaben");
const alle = f.daten?.aufgaben || [];
ok(alle.some((x) => x.titel === "Umsetzen: Zuschauer-Challenge am Freitag"
&& x.verantwortlich_id === idModi), "der Modi setzt es um");
ok(alle.some((x) => x.titel === "Im Live ansagen und die Regeln vorlesen"
&& x.verantwortlich_id === idDogi), "und DogFather bekommt seinen Teil");
/* Aus dem Eingang verschwunden, und ein zweites Mal geht nicht. */
const g = await hole(dogiAn.keks, "/workspace/api/teamlage");
ok((g.daten?.angebote || []).length === 0, "aus dem Eingang verschwunden");
const h = await hole(dogiAn.keks, "/workspace/api/teamlage/angebot", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: ang.id, art: "angenommen" }),
});
ok(h.status === 404, `ein zweites Mal geht nicht (${h.status})`);
/* Und der Modi sieht die Antwort an seinem Angebot. */
const i = await hole(modiAn.keks, "/workspace/api/bereich/angebote");
const seins = (i.daten?.eintraege || i.daten?.liste || []).find((z) => z.id === ang.id);
ok(/Angenommen von/.test(seins?.text || ""),
"der Modi sieht, dass es angenommen wurde");
ok(seins?.status === "angenommen", `und der Zustand steht (${seins?.status})`);
}
/* ---------- 9. Ein Angebot ablehnen ---------------------------------- */
console.log("\n=== 9. Ein Angebot ablehnen ===\n");
{
const a = await hole(modiAn.keks, "/workspace/api/bereich/angebote", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ art: "format", titel: "Wöchentlicher Podcast", bewertung: 2 }),
});
const b = await hole(vanAn.keks, "/workspace/api/teamlage");
const ang = (b.daten?.angebote || [])[0];
if (!ang) { unklar("kein Angebot zum Ablehnen da"); }
else {
const ohne = await hole(vanAn.keks, "/workspace/api/teamlage/angebot", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: ang.id, art: "abgelehnt" }),
});
ok(ohne.status === 400, `ohne Grund geht es nicht (${ohne.status})`);
const mit = await hole(vanAn.keks, "/workspace/api/teamlage/angebot", {
method: "POST", headers: jsonKopf,
body: JSON.stringify({ id: ang.id, art: "abgelehnt", grund: "Zeitlich nicht drin" }),
});
ok(mit.status === 200 && mit.daten?.aufgaben === 0,
`mit Grund geht es, ohne Aufgaben (${mit.status})`);
const c = await hole(modiAn.keks, "/workspace/api/bereich/angebote");
const seins = (c.daten?.eintraege || c.daten?.liste || []).find((z) => z.id === ang.id);
ok(/Zeitlich nicht drin/.test(seins?.text || ""), "der Grund kommt beim Modi an");
}
void a;
} }
console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- ` console.log(`\n${fehler ? "FEHLGESCHLAGEN" : "BESTANDEN"} -- `
+ `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); + `${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
+60 -4
View File
@@ -169,6 +169,47 @@ export const BEREICHE = {
ohneCreatorBezug: true, ohneCreatorBezug: true,
}, },
/* =====================================================================
DIE ANGEBOTE (10.09.2026, Wunsch Filipe)
*"ich brauch auch noch kategorien wie, angebote, wo die modis
sachen planen und auch entscheiden was ich machen muss wenn das und
das erledigt wird."*
Ein Modi schlaegt etwas vor -- eine Aktion, ein Format, eine Regel
-- und schreibt dazu, was DogFather dafuer tun muesste. DogFather
oder VanVan nimmt an oder lehnt ab; bei Annahme entstehen die
Aufgaben. Entschieden wird in der Team-Lage, an derselben Stelle
wie die uebrigen Rueckmeldungen: EIN Eingang, nicht zwei.
DIE ZWEI ZAHLEN SIND UMBENANNT, nicht neu erfunden: `bewertung`
(1-5) heisst hier NUTZEN, `dringlichkeit` heisst AUFWAND. Beide
Felder gibt es laengst; sie mit neuen Namen zu belegen ist besser,
als zwei weitere Spalten anzulegen, die dasselbe koennen. Damit
die Oberflaeche nicht "Dringlichkeit" ueber den Aufwand schreibt,
stehen die Namen hier.
KEIN `fuerAlle`, wie beim Ideen-Board: Wer die Angebote sieht,
entscheidet dieselbe Regel wie ueberall. */
angebote: {
name: "Angebote",
arten: {
aktion: "Aktion",
format: "Format",
regel: "Regel",
technik: "Technik",
sonstiges: "Sonstiges",
},
ober: "Vorschläge",
bewertung: true,
bewertungName: "Nutzen",
dringlichkeit: true,
dringlichkeitName: "Aufwand",
einsatzFeld: true,
einsatzName: "Was DogFather dafür tun müsste",
ohneCreatorBezug: true,
},
agentur: { agentur: {
name: "Agentur", name: "Agentur",
arten: { arten: {
@@ -235,10 +276,12 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet);
ZWEI SCHLOESSER, ABSICHTLICH: Hier haengt der ZUGANG zur Seite, ZWEI SCHLOESSER, ABSICHTLICH: Hier haengt der ZUGANG zur Seite,
sichtbar() haengt die Bedingung an die ZEILEN. Faellt eines weg, sichtbar() haengt die Bedingung an die ZEILEN. Faellt eines weg,
haelt das andere. */ haelt das andere. */
bereicheRouter.use("/workspace/api/bereich/ideen", (req, res, next) => { for (const geschuetzt of ["ideen", "angebote"]) {
bereicheRouter.use(`/workspace/api/bereich/${geschuetzt}`, (req, res, next) => {
if (siehtModis(req.person)) return next(); if (siehtModis(req.person)) return next();
return res.status(404).json({ fehler: "nicht_gefunden" }); return res.status(404).json({ fehler: "nicht_gefunden" });
}); });
}
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
UND UMGEKEHRT: Ein Modi kommt nur in SEINE Bereiche. UND UMGEKEHRT: Ein Modi kommt nur in SEINE Bereiche.
@@ -457,6 +500,9 @@ const SPALTEN = `
e.hook, e.format, e.saeule_id, e.geplant, e.hook, e.format, e.saeule_id, e.geplant,
e.event_ende, e.event_aufgaben, e.event_regeln, e.event_ende, e.event_aufgaben, e.event_regeln,
e.creator_extern, e.creator_extern,
/* Was DogFather dafuer tun muesste -- nur bei den Angeboten
gefuellt, siehe BEREICHE.angebote. */
e.einsatz,
${externSql("pc.name", "e.creator_extern")} AS creator_name, ${externSql("pc.name", "e.creator_extern")} AS creator_name,
pe.name AS erstellt_name, pe.name AS erstellt_name,
(SELECT name FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_name, (SELECT name FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_name,
@@ -630,6 +676,15 @@ function pruefe(bereich, körper, { neu }) {
Konto; sichtbar bleibt er ueber erstellt_von (siehe sichtbar()). */ Konto; sichtbar bleibt er ueber erstellt_von (siehe sichtbar()). */
externPruefen(körper, aus, "creator", fehler); externPruefen(körper, aus, "creator", fehler);
/* Das Einsatz-Feld gibt es nur in Bereichen, die es kennen. Ein
Aufruf an der Oberflaeche vorbei soll nicht in jedem Bereich eine
Spalte fuellen koennen, die dort nichts bedeutet. */
if (einstellung.einsatzFeld && körper.einsatz !== undefined) {
const t = String(körper.einsatz ?? "").trim();
if (t.length > TEXT_MAX) fehler.push("Der Einsatz ist zu lang.");
else aus.einsatz = t || null;
}
/* Ein Agentur-Eintrag gehoert NIEMANDEM einzeln (07.09.2026). /* Ein Agentur-Eintrag gehoert NIEMANDEM einzeln (07.09.2026).
Er gilt fuer alle, und genau deshalb sieht ihn auch jeder (siehe Er gilt fuer alle, und genau deshalb sieht ihn auch jeder (siehe
@@ -704,13 +759,14 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
INSERT INTO eintraege INSERT INTO eintraege
(bereich, art, titel, text, datum, bewertung, dringlichkeit, status, (bereich, art, titel, text, datum, bewertung, dringlichkeit, status,
creator_id, creator_extern, erstellt, erstellt_von, hook, format, saeule_id, geplant, creator_id, creator_extern, erstellt, erstellt_von, hook, format, saeule_id, geplant,
event_ende, event_aufgaben, event_regeln) event_ende, event_aufgaben, event_regeln, einsatz)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, bereich, aus.art, aus.titel, aus.text ?? null, aus.datum,
aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen", aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen",
aus.creator_id ?? null, aus.creator_extern ?? null, jetzt(), req.person.id, aus.creator_id ?? null, aus.creator_extern ?? null, jetzt(), req.person.id,
aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null, aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null,
aus.event_ende ?? null, aus.event_aufgaben ?? null, aus.event_regeln ?? null); aus.event_ende ?? null, aus.event_aufgaben ?? null, aus.event_regeln ?? null,
aus.einsatz ?? null);
protokolliere("eintrag_angelegt", { protokolliere("eintrag_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+92 -8
View File
@@ -38,7 +38,7 @@ import {
protokolliere, echteIp, protokolliere, echteIp,
siehtModis, siehtModis,
} from "./workspace.js"; } from "./workspace.js";
import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; import { MODI_PUNKTE, MODI_GRUPPENTEXTE } from "./workspace-modi-punkte.js";
import { import {
CONTENT_IDEEN, LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE, CONTENT_IDEEN, LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE,
} from "./workspace-vorlagen.js"; } from "./workspace-vorlagen.js";
@@ -147,8 +147,17 @@ function gruppenFuer(bereich, fuerModi) {
/* Eine Gruppe ohne Punkte faellt heraus statt leer dazustehen -- ein /* Eine Gruppe ohne Punkte faellt heraus statt leer dazustehen -- ein
Abschnitt mit einer Ueberschrift und nichts darunter sieht aus wie Abschnitt mit einer Ueberschrift und nichts darunter sieht aus wie
ein Fehler. */ ein Fehler. */
/* Auch der Satz UNTER der Ueberschrift wird getauscht. Der der
Creator-Fassung spricht von etwas anderem ("Vor dem Ernstfall
abhaken") -- und ein Satz, der etwas anderes verspricht als die
Liste darunter, ist schlimmer als gar keiner. */
const texte = MODI_GRUPPENTEXTE[bereich] || {};
return liste.gruppen return liste.gruppen
.map((g) => ({ ...g, punkte: satz[g.schluessel] || [] })) .map((g) => ({
...g,
text: texte[g.schluessel] || g.text,
punkte: satz[g.schluessel] || [],
}))
.filter((g) => g.punkte.length); .filter((g) => g.punkte.length);
} }
@@ -197,6 +206,38 @@ function tabellen() {
CREATE INDEX IF NOT EXISTS idx_punkt_nachricht CREATE INDEX IF NOT EXISTS idx_punkt_nachricht
ON punkt_nachricht (bereich, schluessel, creator_id); ON punkt_nachricht (bereich, schluessel, creator_id);
`); `);
/* =====================================================================
WURDE DIE RUECKMELDUNG SCHON BEHANDELT? (10.09.2026)
Seit die Modis melden statt bewertet zu werden, ist ein
"Verbessern" ein Eingang: DogFather oder VanVan macht eine Aufgabe
daraus oder sagt begruendet ab. Ohne diesen Vermerk stuende jede
Meldung ewig im Eingang -- und ein Eingang, der nie leer wird,
wird nicht mehr angesehen.
ALTER TABLE ADD COLUMN und kein Tabellenneubau: Es aendert sich
keine CHECK-Regel, und SQLite haengt eine Spalte anstandslos an.
`behandelt_art` statt eines Ja/Nein: "daraus wurde eine Aufgabe"
und "das ist nicht noetig" sind zwei verschiedene Antworten, und
der Modi soll beide unterscheiden koennen. */
for (const [spalte, typ] of [
["behandelt_am", "TEXT"],
["behandelt_von", "INTEGER"],
["behandelt_art", "TEXT"],
["aufgabe_id", "INTEGER"],
]) {
try {
const vorhanden = db().prepare("PRAGMA table_info(punkt_stand)").all().map((z) => z.name);
if (vorhanden.length && !vorhanden.includes(spalte)) {
db().exec(`ALTER TABLE punkt_stand ADD COLUMN ${spalte} ${typ}`);
console.log(`[workspace] Spalte '${spalte}' in punkt_stand ergaenzt.`);
}
} catch (fehler) {
console.error(`[workspace] Spalte '${spalte}':`, fehler?.message);
}
}
bereit = true; bereit = true;
} }
@@ -343,10 +384,29 @@ checklisteRouter.get("/workspace/api/checkliste/:bereich", (req, res) => {
/* Die Oberflaeche soll nicht raten muessen, wer was darf -- sonst /* Die Oberflaeche soll nicht raten muessen, wer was darf -- sonst
zeigt sie einem Creator einen Knopf, der beim Klicken 403 sagt. zeigt sie einem Creator einen Knopf, der beim Klicken 403 sagt.
Das ist schlechter als kein Knopf. */ Das ist schlechter als kein Knopf. */
/* Wer an der EIGENEN Liste arbeitet, bewertet sie nicht selbst -- /* =============================================================
weder Creator noch Modi. Die Stufe setzt die Betreuung WER DRUECKT, HAENGT DAVON AB, WESSEN LISTE ES IST
beziehungsweise die DogFather-Rolle. */ (umgedreht am 10.09.2026)
darf_bewerten: req.person.rolle !== "creator" && req.person.rolle !== "modi",
Wunsch Filipe: *"die modis mir quasi aufgaben verteilen, also
das sie drücken können passt so oder verbesserungen. weil die
modis sind ja da um mir zu helfen."*
BEI EINER CREATOR-LISTE wie bisher: Die Betreuung bewertet,
der Creator nicht sich selbst.
BEI EINER MODI-LISTE genau andersherum: NUR ER druecke. Es ist
SEIN Bericht ueber die Sendung -- was er gesehen und gehoert
hat. Duerfte DogFather ihn ueberschreiben, waere es nicht mehr
sein Bericht, sondern eine Abstimmung darueber, was er gesehen
haben soll.
DogFather und VanVan LESEN ihn und handeln in der Team-Lage:
Aus "Verbessern" wird dort mit einem Klick eine Aufgabe -- oder
eine begruendete Absage, die beim Modi ankommt. */
darf_bewerten: zielRolle === "modi"
? req.person.id === creatorId
: (req.person.rolle !== "creator" && req.person.rolle !== "modi"),
ich: req.person.id, ich: req.person.id,
}); });
} catch (fehler) { } catch (fehler) {
@@ -362,7 +422,23 @@ checklisteRouter.put("/workspace/api/checkliste/:bereich/:schluessel", gleicheHe
try { try {
/* Ein Creator bewertet sich nicht selbst. Waere das moeglich, /* Ein Creator bewertet sich nicht selbst. Waere das moeglich,
stuende alles auf gruen und die Ampel waere wertlos. */ stuende alles auf gruen und die Ampel waere wertlos. */
if (req.person.rolle === "creator" || req.person.rolle === "modi") { /* Wessen Liste ist es? Danach entscheidet sich, wer druecken
darf -- siehe die Begruendung beim Lesen weiter oben. */
const zielId = creatorAus(req);
const zielRolle = zielId > 0
? db().prepare("SELECT rolle FROM personen WHERE id = ?").get(zielId)?.rolle
: null;
const eigenerBericht = zielRolle === "modi" && req.person.id === zielId;
if (zielRolle === "modi" && !eigenerBericht) {
/* Auch DogFather nicht: Es ist der Bericht des Modis. Er
handelt in der Team-Lage, nicht hier. */
return res.status(403).json({
fehler: "Das ist die Rückmeldung des Modis. Sie wird nicht überschrieben, "
+ "sondern in der Team-Lage behandelt.",
});
}
if (!eigenerBericht
&& (req.person.rolle === "creator" || req.person.rolle === "modi")) {
return res.status(403).json({ return res.status(403).json({
fehler: "Die Bewertung setzt deine Betreuung. Du kannst hier antworten.", fehler: "Die Bewertung setzt deine Betreuung. Du kannst hier antworten.",
}); });
@@ -391,6 +467,8 @@ checklisteRouter.put("/workspace/api/checkliste/:bereich/:schluessel", gleicheHe
geloescht statt auf "offen" gesetzt -- ein Datensatz, der nichts geloescht statt auf "offen" gesetzt -- ein Datensatz, der nichts
aussagt, ist Ballast. */ aussagt, ist Ballast. */
if (stufe === "offen") { if (stufe === "offen") {
/* Auch der Vermerk faellt mit -- eine neu gesetzte Stufe ist
eine neue Meldung, keine schon behandelte. */
db().prepare("DELETE FROM punkt_stand WHERE bereich = ? AND schluessel = ? AND creator_id = ?") db().prepare("DELETE FROM punkt_stand WHERE bereich = ? AND schluessel = ? AND creator_id = ?")
.run(bereich, schluessel, creatorId); .run(bereich, schluessel, creatorId);
} else { } else {
@@ -399,7 +477,13 @@ checklisteRouter.put("/workspace/api/checkliste/:bereich/:schluessel", gleicheHe
VALUES (?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?)
ON CONFLICT(bereich, schluessel, creator_id) DO UPDATE SET ON CONFLICT(bereich, schluessel, creator_id) DO UPDATE SET
stufe = excluded.stufe, begruendung = excluded.begruendung, stufe = excluded.stufe, begruendung = excluded.begruendung,
von = excluded.von, am = excluded.am`) von = excluded.von, am = excluded.am,
/* Eine neu gesetzte Stufe ist eine NEUE Meldung -- der
alte Vermerk gehoert weg, sonst verschwindet sie
stillschweigend aus dem Eingang, obwohl sich etwas
geaendert hat. */
behandelt_am = NULL, behandelt_von = NULL,
behandelt_art = NULL, aufgabe_id = NULL`)
.run(bereich, schluessel, creatorId, stufe, grund || null, req.person.id, jetzt()); .run(bereich, schluessel, creatorId, stufe, grund || null, req.person.id, jetzt());
} }
+253 -229
View File
@@ -1,278 +1,302 @@
/* ===================================================================== /* =====================================================================
DIE CHECKLISTEN-PUNKTE EINES MODIS (10.09.2026) WAS DIE MODIS BEURTEILEN (10.09.2026, umgedreht am selben Tag)
Wunsch Filipe: *"diese kategorien mussen auf dieser seite auch noch Wunsch Filipe: *"diese 3 kategorien müssen so eingestellt werden dass
auf die modis perfektionniert werden, da mussen lauter sachen sein die modis mir quasi aufgaben verteilen, also das sie drücken können
die vanvan und ich danach verteilen können und da anpassen können ob passt so oder verbesserungen. weil die modis sind ja da um mir zu
gut oder nicht."* helfen."*
WARUM EIN ZWEITER SATZ UND NICHT EIN ERWEITERTER: Die vorhandenen DIE RICHTUNG WAR VORHER FALSCH HERUM. Beim ersten Anlauf standen hier
Punkte (LIVE_PUNKTE, COMMUNITY_PUNKTE, TECHNIK_PUNKTE) sind fuer Punkte ueber die Arbeit des MODIS -- "Schicht bestaetigt", "Chat im
CREATOR geschrieben -- "Upload messen", "Sendeplan festlegen", Blick" -- und DogFather sollte sie bewerten. Das war eine Note fuer
"Verweildauer vergleichen". Ein Modi sendet nicht, er moderiert. Ihm ihn, keine Hilfe fuer DogFather. Und es widerspricht der Hausregel,
dieselbe Liste vorzulegen hiesse, ihn an Dingen zu messen, die nicht nach der niemand ueber dem Team steht.
seine Arbeit sind.
DIESELBEN DREI BEREICHE UND DIESELBEN GRUPPEN wie beim Creator -- JETZT BEURTEILT DER MODI DIE SENDUNG. Er sitzt im Chat, er sieht und
Vor/Waehrend/Nach, Moderation/Aktion/Konflikt, Setup/Problem/Loesung. hoert, was die Zuschauer sehen und hoeren -- frueher und ungefilterter
Das ist Absicht: Zwei verschiedene Einteilungen fuer dieselbe Sache als jeder, der gerade sendet. Er drueckt "Passt so" oder
waeren zwei Dinge zum Lernen statt einem, und die Oberflaeche kaeme "Verbessern", schreibt einen Satz dazu, und daraus wird ein Vorschlag
ohne Umbau nicht damit zurecht. fuer DogFather.
DIESELBEN VIER STUFEN (anfaenger, fort, profi, meister). Sie DIE PUNKTE SIND DESHALB BEOBACHTUNGEN, KEINE PFLICHTEN. Nicht "Ton
entscheiden, was jemandem zuerst gezeigt wird. Ein Modi in der pruefen", sondern "Der Ton war durchgehend verstaendlich". Der
Probezeit soll nicht an "Muster ueber mehrere Sendungen erkennen" Unterschied ist nicht sprachlich: Eine Pflicht kann man erfuellen
gemessen werden. oder versaeumen, eine Beobachtung kann man nur machen -- und
niemandem vorwerfen.
DIE SCHLUESSEL BEGINNEN MIT "m-". Der Stand haengt in `punkt_stand` WAS EIN MODI SELBST ZU TUN HAT, steht woanders: im Aufgabenkatalog
an (bereich, schluessel, person) -- ein Modi und ein Creator im aus Teil 2 des Anforderungsdokuments (60 Aufgaben, neun Etappen).
selben Bereich duerfen sich also nicht in die Quere kommen. Mit dem Das ist die richtige Stelle dafuer -- eine Aufgabe hat eine Frist und
Praefix kann das nicht passieren, auch wenn jemand spaeter einen einen Status, eine Beobachtung nicht.
Punkt gleich benennt.
BEWERTET WIRD NICHT VON IHM SELBST, sondern von der DogFather-Rolle DIE VIER STUFEN bleiben. Sie sagen hier, wie GENAU jemand hinsieht:
-- offen / gut / verbessern, wie beim Creator auch. Genau das war "Ton war da" sieht jeder, "es gab einen Grund wiederzukommen" sieht,
Filipes Wunsch: "verteilen ... und anpassen ob gut oder nicht". wer laenger dabei ist. Ein Modi in der Probezeit soll nicht mit den
Meister-Punkten anfangen muessen.
DIE SCHLUESSEL BEGINNEN MIT "m-". Der Stand haengt an (bereich,
schluessel, person) -- ohne Praefix koennte ein Modi-Punkt eines
Tages denselben Namen tragen wie ein Creator-Punkt und dessen
Bewertung erben.
===================================================================== */ ===================================================================== */
/* Die Ueberschriften und Saetze je Gruppe. Sie stehen hier, weil die
der Creator-Fassung von etwas anderem sprechen ("Vor dem Ernstfall
abhaken") -- und ein Satz, der etwas anderes verspricht als die
Liste darunter, ist schlimmer als gar keiner. */
export const MODI_GRUPPENTEXTE = {
live: {
vorbereitung: "Was du vor und beim Start gesehen hast. Der Anfang entscheidet, ob jemand bleibt.",
mitschrift: "Was währenddessen auffiel. Jetzt notieren – morgen ist es weg.",
auswertung: "Was danach passiert ist. Auch das gehört zur Sendung.",
},
community: {
moderation: "Wie die Regeln im Alltag wirken – nicht, wie sie gemeint sind.",
aktion: "Ob die Community etwas zurückbekommt.",
konflikt: "Wie es lief, als es eng wurde.",
},
technik: {
setup: "Was bei den Zuschauern ankommt. Du hörst und siehst es wie sie.",
problem: "Wenn etwas ausgefallen ist.",
loesung: "Was danach geblieben ist.",
},
};
export const MODI_LIVE_PUNKTE = { export const MODI_LIVE_PUNKTE = {
vorbereitung: [ vorbereitung: [
{ schluessel: "m-schicht-bestaetigt", titel: "Schicht bestätigt", stufe: "anfaenger", { schluessel: "m-ankuendigung-rechtzeitig", titel: "Die Ankündigung kam rechtzeitig", stufe: "anfaenger",
text: "Vor dem Tag gesagt, ob du kannst. Ein „ich schau mal“ ist keine Zusage – und das Team plant danach." }, text: "30 bis 60 Minuten vorher. Später erreicht sie die nicht mehr, die sich den Abend noch einteilen." },
{ schluessel: "m-ankuendigung-gesehen", titel: "Ankündigung gesehen", stufe: "anfaenger", { schluessel: "m-titel-sagt-worum", titel: "Der Titel sagte, worum es geht", stufe: "anfaenger",
text: "Du weißt, was draußen versprochen wurde. Sonst beantwortest du im Chat Fragen zu etwas, das du nicht kennst." }, text: "Wer ihn liest, muss wissen, ob es für ihn ist. Ein Titel, der neugierig macht und nichts sagt, holt die Falschen." },
{ schluessel: "m-ablauf-gelesen", titel: "Ablaufplan gelesen", stufe: "anfaenger", { schluessel: "m-startzeit-gehalten", titel: "Die Startzeit wurde gehalten", stufe: "anfaenger",
text: "Anfang, Mitte, Schluss – grob im Kopf. Wer den Ablauf kennt, kann den Chat darauf vorbereiten." }, text: "Wer zur angekündigten Zeit kommt und nichts vorfindet, kommt beim nächsten Mal später – oder nicht." },
{ schluessel: "m-regeln-griffbereit", titel: "Regeln griffbereit", stufe: "anfaenger", { schluessel: "m-ton-von-anfang-an", titel: "Der Ton war von Anfang an da", stufe: "anfaenger",
text: "Der Link oder der Text, den du hineinstellst, ohne zu suchen. Im Ernstfall zählen Sekunden." }, text: "Die ersten Sekunden ohne Ton kosten mehr Zuschauer als jede spätere Panne." },
{ schluessel: "m-eigene-technik", titel: "Eigene Technik geprüft", stufe: "anfaenger", { schluessel: "m-bild-hell-genug", titel: "Das Bild war hell genug", stufe: "anfaenger",
text: "Dein Gerät, deine Verbindung, dein Zugang. Ein Modi, der mittendrin herausfliegt, fehlt genau dann." }, text: "Auf dem Handy sieht es dunkler aus als am Rechner. Wenn du das Gesicht schlecht erkennst, erkennen es andere gar nicht." },
{ schluessel: "m-wer-ist-dabei", titel: "Wer moderiert heute noch?", stufe: "fort", { schluessel: "m-einstieg-vorhanden", titel: "Es gab einen Einstieg", stufe: "fort",
text: "Zu zweit teilt man sich auf – wer begrüßt, wer greift ein. Allein macht man beides halb." }, text: "Nicht mitten im Satz anfangen. Wer in den ersten Sekunden nicht weiß, wo er gelandet ist, geht wieder." },
{ schluessel: "m-ziel-bekannt", titel: "Ziel der Sendung bekannt", stufe: "fort", { schluessel: "m-thema-frueh-klar", titel: "Das Thema war früh klar", stufe: "fort",
text: "Eine Zahl oder eine Sache. Darauf kannst du im Chat hinweisen, ohne zu fragen." }, text: "Spätestens nach zwei Minuten. Alles danach ist Suchen, und Suchen macht man woanders." },
{ schluessel: "m-gaeste-bekannt", titel: "Gäste bekannt", stufe: "fort", { schluessel: "m-chat-wusste-bescheid", titel: "Der Chat wusste, was kommt", stufe: "fort",
text: "Wer kommt, wann, und wie heißt er richtig. Ein falsch geschriebener Name im Chat bleibt hängen." }, text: "Ein Satz zum Ablauf am Anfang spart dir zwanzig Fragen später." },
{ schluessel: "m-wortfilter-durchgesehen", titel: "Wortfilter durchgesehen", stufe: "fort", { schluessel: "m-technikcheck-war-durch", titel: "Der Technik-Check war vorher durch", stufe: "fort",
text: "Was letzte Woche durchrutschte, gehört heute hinein. Ein Filter, den niemand pflegt, veraltet." }, text: "Man merkt es daran, ob in den ersten Minuten noch geschraubt wird. Das sehen die Zuschauer auch." },
{ schluessel: "m-eskalationsweg", titel: "Eskalationsweg im Kopf", stufe: "fort", { schluessel: "m-gaeste-angekuendigt", titel: "Gäste waren angekündigt", stufe: "fort",
text: "Ab wann gibst du weiter, statt selbst zu entscheiden – und an wen. Vorher klären, nicht im Moment." }, text: "Wer im Chat sitzt, will wissen, wer da ist – und du willst den Namen richtig schreiben können." },
{ schluessel: "m-begruessung-bereit", titel: "Begrüßungssätze bereit", stufe: "profi", { schluessel: "m-einstieg-zog", titel: "Der Einstieg hat gezogen", stufe: "profi",
text: "Drei verschiedene, nicht einer dreimal. Wer merkt, dass ein Baustein antwortet, antwortet nicht zurück." }, text: "Blieben die ersten, die kamen? Über 70 Prozent entscheiden in den ersten Sekunden." },
{ schluessel: "m-umfrage-vorbereitet", titel: "Umfrage vorbereitet", stufe: "profi", { schluessel: "m-plan-erkennbar", titel: "Ein Plan war erkennbar", stufe: "profi",
text: "Höchstens drei Antworten, vorher aufgeschrieben. Mitten im Stream formuliert man keine gute Frage." }, text: "Nicht als Liste vorgelesen, aber spürbar. Ein Abend ohne Richtung fühlt sich für Zuschauer wie Warten an." },
{ schluessel: "m-vertretung-geklaert", titel: "Vertretung geklärt", stufe: "profi", { schluessel: "m-ankuendigung-passte", titel: "Die Ankündigung passte zum Inhalt", stufe: "profi",
text: "Wer übernimmt, wenn du wegmusst. Ein Chat ohne Aufsicht ist schlimmer als einer, der zu ist." }, text: "Wer wegen etwas anderem gekommen ist, bleibt nicht – und kommt beim nächsten Mal nicht wieder." },
{ schluessel: "m-uebergabe-vorbereitet", titel: "Übergabe an die nächste Schicht", stufe: "meister", { schluessel: "m-start-wiederholbar", titel: "Der Start war wiederholbar", stufe: "meister",
text: "Was die andere Person wissen muss, bevor du gehst. Zwei Zeilen reichen – aber sie müssen da sein." }, text: "So gut, dass man ihn genauso noch einmal machen könnte. Genau das macht aus einem guten Abend ein Format." },
], ],
mitschrift: [ mitschrift: [
{ schluessel: "m-chat-im-blick", titel: "Chat wirklich im Blick", stufe: "anfaenger", { schluessel: "m-ton-durchgehend", titel: "Der Ton blieb verständlich", stufe: "anfaenger",
text: "Nicht nebenbei. Wer mitschaut statt mitliest, sieht den Verstoß erst, wenn zehn Leute geantwortet haben." }, text: "Durchgehend, nicht überwiegend. Ein Aussetzer alle zehn Minuten reicht, damit jemand geht." },
{ schluessel: "m-neue-begruesst", titel: "Neue begrüßt", stufe: "anfaenger", { schluessel: "m-bild-ohne-ruckeln", titel: "Das Bild lief ohne Ruckeln", stufe: "anfaenger",
text: "Mit Namen, wenn es geht. Wer beim ersten Mal angesprochen wird, kommt deutlich häufiger wieder." }, text: "Wenn es bei dir ruckelt, ruckelt es bei vielen. Notiere die Uhrzeit – daran erkennt man die Ursache." },
{ schluessel: "m-spam-sofort-weg", titel: "Spam sofort weg", stufe: "anfaenger", { schluessel: "m-auf-chat-reagiert", titel: "Auf den Chat kam Reaktion", stufe: "anfaenger",
text: "Löschen, bevor jemand antwortet. Eine Diskussion über einen gelöschten Beitrag kostet mehr als der Beitrag." }, text: "Nicht auf alles, aber sichtbar. Ein Chat, der nie vorkommt, hört auf zu schreiben." },
{ schluessel: "m-fragen-gesammelt", titel: "Fragen gesammelt", stufe: "anfaenger", { schluessel: "m-namen-richtig", titel: "Namen wurden richtig gesagt", stufe: "anfaenger",
text: "An einer Stelle, nicht im Kopf. Was im Chat vorbeirauscht, ist nach zwei Minuten weg." }, text: "Wer falsch angesprochen wird, korrigiert einmal und schweigt dann." },
{ schluessel: "m-auf-ziel-hingewiesen", titel: "Auf das Ziel hingewiesen", stufe: "fort", { schluessel: "m-roter-faden", titel: "Der rote Faden war erkennbar", stufe: "fort",
text: "Lieber zweimal beiläufig als einmal ausführlich. Wer später dazukommt, hat es sonst nie gehört." }, text: "Auch für jemanden, der nach 40 Minuten dazukommt. Der weiß sonst nicht, worum es geht." },
{ schluessel: "m-interaktion-angestossen", titel: "Interaktion angestoßen", stufe: "fort", { schluessel: "m-fragen-beantwortet", titel: "Fragen bekamen eine Antwort", stufe: "fort",
text: "Eine Frage in den Chat, wenn es still wird. Stille im Chat sieht für Neue aus wie ein leerer Raum." }, text: "Oder wenigstens ein „später“. Eine übergangene Frage wird kein zweites Mal gestellt." },
{ schluessel: "m-clipmomente-markiert", titel: "Clip-Momente markiert", stufe: "fort", { schluessel: "m-ziel-erwaehnt", titel: "Das Ziel wurde erwähnt", stufe: "fort",
text: "Mit Uhrzeit, sofort. Am nächsten Tag findet niemand die Stelle wieder, an der alle gelacht haben." }, text: "Mindestens zweimal, beiläufig. Wer erst spät dazukommt, hat es sonst nie gehört." },
{ schluessel: "m-zeit-im-blick", titel: "Zeit im Blick behalten", stufe: "fort", { schluessel: "m-pausen-angesagt", titel: "Pausen waren angesagt", stufe: "fort",
text: "Ein kurzes Zeichen genügt. Wer sendet, hat kein Gefühl für die Uhr – das ist normal." }, text: "Ein stiller Bildschirm ohne Ansage sieht aus wie ein Absturz." },
{ schluessel: "m-verwarnen-vor-sperren", titel: "Erst verwarnen, dann sperren", stufe: "profi", { schluessel: "m-tempo-stimmte", titel: "Das Tempo stimmte", stufe: "profi",
text: "Außer bei den Sachen, die sofort gehen müssen. Eine Verwarnung, die jeder sieht, wirkt auf mehr als einen." }, text: "Nicht gehetzt, nicht zäh. Du merkst es am Chat: Wird er still, war es zu lang." },
{ schluessel: "m-stammzuschauer-angesprochen", titel: "Stammzuschauer angesprochen", stufe: "profi", { schluessel: "m-auf-stimmung-reagiert", titel: "Auf die Stimmung wurde reagiert", stufe: "profi",
text: "Wiedererkannt zu werden ist der halbe Grund wiederzukommen." }, text: "Wenn der Chat kippt oder einschläft, muss etwas passieren. Du siehst es zuerst." },
{ schluessel: "m-eskalation-weitergegeben", titel: "Eskalation weitergegeben", stufe: "profi", { schluessel: "m-gaeste-eingebunden", titel: "Gäste wurden eingebunden", stufe: "profi",
text: "Nach der Regel, nicht nach Gefühl. Allein zu entscheiden, wo die Regel etwas anderes sagt, bringt dich in die Schusslinie." }, text: "Ein Gast, der zwanzig Minuten zuhört, ist kein Gast. Das fällt im Chat sofort auf." },
{ schluessel: "m-nichts-oeffentlich-ausdiskutiert", titel: "Nichts öffentlich ausdiskutiert", stufe: "profi", { schluessel: "m-chat-nicht-ueberhoert", titel: "Der Chat wurde nicht überhört", stufe: "profi",
text: "Im Chat gewinnt niemand. Kurz sagen, was gilt, und weiter – der Rest gehört ins Team." }, text: "Gerade wenn es voll wird. Genau dann fühlen sich die Leisen übergangen." },
{ schluessel: "m-stimmung-gemeldet", titel: "Stimmung gemeldet", stufe: "meister", { schluessel: "m-abschluss-gesetzt", titel: "Der Abschluss war gesetzt", stufe: "meister",
text: "Wenn es kippt, bevor es kippt. Du siehst es früher als jeder, der gerade sendet." }, text: "Nicht abgebrochen. Ein Ende, das man kommen sieht, ist der Grund, beim nächsten Mal bis zum Schluss zu bleiben." },
{ schluessel: "m-ruhig-geblieben", titel: "Ruhig geblieben, als es eng wurde", stufe: "meister", { schluessel: "m-grund-wiederzukommen", titel: "Es gab einen Grund wiederzukommen", stufe: "meister",
text: "Der Ton eines Modis wird zum Ton des Chats. Das ist der schwerste Punkt auf dieser Liste." }, text: "Etwas Angekündigtes, etwas Offenes, etwas Verabredetes. Ohne das entscheidet der Zufall." },
], ],
auswertung: [ auswertung: [
{ schluessel: "m-auffaelligkeiten-notiert", titel: "Auffälligkeiten notiert", stufe: "anfaenger", { schluessel: "m-aufzeichnung-da", titel: "Die Aufzeichnung war da", stufe: "anfaenger",
text: "Solange du es noch weißt. Am nächsten Tag fehlt die Hälfte, und zwar die interessante." }, text: "Am selben Abend gesichert. Ohne sie gibt es keine Clips und keinen Beleg." },
{ schluessel: "m-clipmarken-weitergegeben", titel: "Clip-Marken weitergegeben", stufe: "anfaenger", { schluessel: "m-clips-kamen", titel: "Es kamen Clips", stufe: "anfaenger",
text: "An die, die schneiden. Eine Marke, die niemand bekommt, ist keine." }, text: "Mindestens einer, am nächsten Tag. Der Feed hat mehr Reichweite als der Stream." },
{ schluessel: "m-feedback-im-teamchat", titel: "Kurzes Feedback im Team-Chat", stufe: "anfaenger", { schluessel: "m-offene-fragen-nachgereicht", titel: "Offene Fragen wurden nachgereicht", 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." }, 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", { schluessel: "m-team-bekam-rueckmeldung", titel: "Das Team bekam Rückmeldung", stufe: "fort",
text: "Aufgeschrieben, nicht vorgenommen. Ein Vorsatz ohne Zettel ist am Freitag weg." }, text: "Auch wenn alles gut lief. Wer nie hört, dass es gut war, weiß nicht, was er behalten soll." },
{ schluessel: "m-regel-vorgeschlagen", titel: "Regel vorgeschlagen, wo eine fehlte", stufe: "profi", { schluessel: "m-zahlen-geteilt", titel: "Zahlen wurden geteilt", stufe: "fort",
text: "Wenn du zweimal improvisieren musstest, fehlt eine Regel – nicht Erfahrung." }, text: "Nicht alle – die zwei, die etwas sagen. Ein Team, das die Wirkung nicht kennt, arbeitet blind." },
{ schluessel: "m-wiederkehrende-stoerer", titel: "Wiederkehrende Störer notiert", stufe: "profi", { schluessel: "m-angekuendigtes-gehalten", titel: "Angekündigtes wurde eingehalten", stufe: "fort",
text: "Dieselben Namen tauchen wieder auf. Eine Liste macht aus drei Einzelfällen ein Muster." }, text: "„Nächste Woche machen wir …“ – wenn das dreimal nicht kam, glaubt es niemand mehr." },
{ schluessel: "m-zahlen-angesehen", titel: "Zahlen angesehen", stufe: "profi", { schluessel: "m-aus-hinweisen-wurde-etwas", titel: "Aus Hinweisen wurde etwas", stufe: "profi",
text: "Wie viele kamen, wie lange blieben sie. Nicht um zu bewerten, sondern um zu wissen, wann der Chat kippte." }, text: "Was das Team gemeldet hat, taucht beim nächsten Mal auf – oder es kommt eine Absage. Beides ist in Ordnung, Schweigen nicht." },
{ schluessel: "m-uebergabe-geschrieben", titel: "Übergabe geschrieben", stufe: "meister", { schluessel: "m-naechster-termin-stand", titel: "Der nächste Termin stand", stufe: "profi",
text: "So, dass jemand anderes die nächste Sendung übernehmen könnte. Wer nicht ersetzbar ist, kann nicht krank werden." }, text: "Vor dem Ende gesagt und im Kalender. Zwei feste Tage schlagen fünf zufällige." },
{ schluessel: "m-muster-ueber-sendungen", titel: "Muster über mehrere Sendungen erkannt", stufe: "meister", { schluessel: "m-wiederkehrendes-angegangen", titel: "Wiederkehrendes wurde angegangen", stufe: "profi",
text: "Immer dieselbe Uhrzeit, immer dieselbe Sorte Kommentar. Das sieht nur, wer mehrere Abende nebeneinanderlegt." }, text: "Derselbe Aussetzer, dieselbe Frage, derselbe Störer – beim dritten Mal ist es kein Zufall mehr." },
{ schluessel: "m-geaendert-nicht-nur-besprochen", titel: "Es wurde geändert, nicht nur besprochen", stufe: "meister",
text: "Der Unterschied zwischen einer Nachbesprechung und einer Verbesserung." },
{ schluessel: "m-uebergabefaehig", titel: "Die Sendung war übergabefähig", stufe: "meister",
text: "So dokumentiert, dass jemand anderes sie fahren könnte. Wer nicht ersetzbar ist, kann nicht krank werden." },
{ schluessel: "m-team-genannt", titel: "Das Team wurde genannt", stufe: "meister",
text: "Öffentlich und namentlich. Wer nur im Hintergrund vorkommt, bleibt irgendwann ganz weg." },
], ],
}; };
export const MODI_COMMUNITY_PUNKTE = { export const MODI_COMMUNITY_PUNKTE = {
moderation: [ moderation: [
{ schluessel: "m-regeln-gelesen", titel: "Regeln gelesen und verstanden", stufe: "anfaenger", { schluessel: "m-regeln-auffindbar", titel: "Die Regeln waren auffindbar", stufe: "anfaenger",
text: "Nicht überflogen. Du wirst danach gefragt, und eine falsche Auskunft ist schwerer zurückzunehmen als keine." }, text: "An einer Stelle, die man ohne Suchen findet. Was man suchen muss, liest niemand." },
{ schluessel: "m-eigene-rechte-bekannt", titel: "Eigene Rechte bekannt", stufe: "anfaenger", { schluessel: "m-regeln-verstaendlich", titel: "Die Regeln waren verständlich", stufe: "anfaenger",
text: "Was du darfst: löschen, stummschalten, sperren – und was nicht. Wer es nicht weiß, tut zu viel oder zu wenig." }, text: "In eigenen Worten, nicht als Rechtstext. Regeln, die man erklären muss, werden nicht befolgt." },
{ schluessel: "m-tonfall", titel: "Tonfall gefunden", stufe: "anfaenger", { schluessel: "m-wortfilter-passte", titel: "Der Wortfilter passte noch", stufe: "anfaenger",
text: "Freundlich und kurz. Ein Modi, der sich rechtfertigt, verliert; einer, der herrisch wird, auch." }, text: "Was diese Woche durchrutschte, gehört hinein. Ein Filter, den niemand pflegt, veraltet." },
{ schluessel: "m-haeufige-fragen", titel: "Häufige Fragen im Kopf", stufe: "anfaenger", { schluessel: "m-klar-wer-eingreift", titel: "Es war klar, wer eingreifen darf", stufe: "anfaenger",
text: "Die fünf, die jeden Abend kommen. Wer sie kennt, muss nicht jedes Mal nachsehen." }, text: "Für dich und für den Chat. Unklarheit darüber kostet im Ernstfall die entscheidenden Sekunden." },
{ schluessel: "m-warnung-vor-sperre", titel: "Reihenfolge klar: Hinweis, Warnung, Sperre", stufe: "fort", { schluessel: "m-eskalationsregel-bekannt", titel: "Die Eskalationsregel war bekannt", stufe: "fort",
text: "Vorher entscheiden, nicht im Moment. Wer im Ärger sperrt, sperrt irgendwann falsch." }, text: "Ab wann gibt man weiter statt selbst zu entscheiden. Wer das erst im Ernstfall klärt, klärt es zu spät." },
{ schluessel: "m-minderjaehrige", titel: "Umgang mit Minderjährigen", stufe: "fort", { schluessel: "m-regeln-angewandt", titel: "Die Regeln wurden auch angewandt", stufe: "fort",
text: "Wie erkennt man sie, was gilt dann. Das ist keine Ermessensfrage." }, text: "Eine Regel, die zweimal übergangen wird, gilt nicht mehr – auch für die, die sich daran halten." },
{ schluessel: "m-privat-vs-oeffentlich", titel: "Privat statt öffentlich", stufe: "fort", { schluessel: "m-entscheidungen-nachvollziehbar", titel: "Entscheidungen waren nachvollziehbar", stufe: "fort",
text: "Was eine Person betrifft, klärt man mit ihr – nicht vor zweihundert Leuten." }, text: "Nicht für jeden im Chat, aber für dich. Eine Sperre, die niemand erklären kann, wird zurückgenommen." },
{ schluessel: "m-keine-versprechen", titel: "Keine Versprechen im Namen anderer", stufe: "fort", { schluessel: "m-alle-gleich-behandelt", titel: "Alle wurden gleich behandelt", 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." }, 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", { schluessel: "m-regeln-passen-noch", titel: "Die Regeln passen noch zur Community", stufe: "profi",
text: "Einen Abend nebenher laufen lassen und danach besprechen. Zusehen lernt mehr als jede Regelliste." }, text: "Eine Community von heute ist nicht die von vor einem Jahr. Regeln altern mit." },
{ schluessel: "m-wiederholungstaeter-erkannt", titel: "Wiederholungstäter wurden erkannt", stufe: "profi",
text: "Dieselben Namen tauchen wieder auf. Ohne Liste bleiben drei Einzelfälle drei Einzelfälle." },
{ schluessel: "m-rueckendeckung", titel: "Es gab Rückendeckung", stufe: "profi",
text: "Wenn ein Modi eingreift und danach allein dasteht, greift beim nächsten Mal keiner mehr ein." },
{ schluessel: "m-aus-vorfaellen-regeln", titel: "Aus Vorfällen wurden Regeln", stufe: "meister",
text: "Ein Vorfall, aus dem nichts folgt, kommt wieder. Der Ertrag ist die Regel, nicht die Sperre." },
], ],
aktion: [ aktion: [
{ schluessel: "m-willkommen-gepflegt", titel: "Willkommens-Routine gepflegt", stufe: "anfaenger", { schluessel: "m-neue-wurden-begruesst", titel: "Neue wurden begrüßt", stufe: "anfaenger",
text: "Ein Satz, der nicht nach Textbaustein klingt. Wer merkt, dass ein Automat schreibt, antwortet nicht." }, text: "Mit Namen. Wer beim ersten Mal angesprochen wird, kommt deutlich häufiger wieder." },
{ schluessel: "m-namen-gemerkt", titel: "Namen gemerkt", stufe: "anfaenger", { schluessel: "m-kommentare-beantwortet", titel: "Kommentare bekamen Antwort", stufe: "anfaenger",
text: "Die zehn, die immer da sind. Mehr braucht es nicht, damit sich eine Community erkannt fühlt." }, text: "Auch die kurzen, auch die späten. Eine Antwort nach drei Tagen liest sich wie keine." },
{ schluessel: "m-fragen-in-den-stream", titel: "Fragen in den Stream gegeben", stufe: "anfaenger", { schluessel: "m-stammzuschauer-erkannt", titel: "Stammzuschauer wurden erkannt", stufe: "anfaenger",
text: "Gesammelt, ausgewählt, weitergereicht. Nicht alles – die drei, die etwas bringen." }, text: "Wiedererkannt zu werden ist der halbe Grund wiederzukommen." },
{ schluessel: "m-umfrage-ausgewertet", titel: "Umfrage ausgewertet", stufe: "fort", { schluessel: "m-etwas-zum-mitmachen", titel: "Es gab etwas zum Mitmachen", stufe: "fort",
text: "Und das Ergebnis gesagt. Eine Abstimmung ohne Ergebnis fragt beim nächsten Mal niemand mehr mit." }, text: "Eine Umfrage, eine Frage, eine Challenge. Zuschauen allein bindet niemanden." },
{ schluessel: "m-aktion-vorgeschlagen", titel: "Eine Aktion vorgeschlagen", stufe: "fort", { schluessel: "m-umfragen-ausgewertet", titel: "Umfragen wurden ausgewertet", stufe: "fort",
text: "Challenge, Q&A, gemeinsamer Abend. Du hörst im Chat, was ankommt, früher als alle anderen." }, text: "Und das Ergebnis gesagt. Eine Abstimmung ohne Ergebnis macht beim nächsten Mal niemand mit." },
{ schluessel: "m-stille-belebt", titel: "Stille belebt statt ausgesessen", stufe: "fort", { schluessel: "m-wuensche-kamen-zurueck", titel: "Wünsche kamen zurück", stufe: "fort",
text: "Eine Frage, ein Bezug auf vorhin. Ein stiller Chat wirkt auf Neue wie ein leerer Raum." }, text: "Auch als Nein. Ein Wunschkanal ohne Antwort verstummt." },
{ schluessel: "m-rueckmeldung-eingesammelt", titel: "Rückmeldungen eingesammelt", stufe: "fort", { schluessel: "m-aktionen-angekuendigt", titel: "Aktionen waren angekündigt", stufe: "fort",
text: "Was Zuschauer sich wünschen, gehört weitergegeben – auch wenn es nicht umgesetzt wird." }, text: "Rechtzeitig genug, dass man sich den Termin freihalten kann." },
{ schluessel: "m-stammzuschauer-liste", titel: "Liste der Stammzuschauer gepflegt", stufe: "profi", { schluessel: "m-community-erwaehnt", titel: "Die Community wurde erwähnt", stufe: "profi",
text: "Wer seit Wochen da ist, verdient mehr als ein Herz. Ohne Liste vergisst man ihn." }, text: "Namentlich, nicht als „ihr da draußen“. Der Unterschied zwischen Publikum und Runde." },
{ schluessel: "m-neue-in-die-runde", titel: "Neue in die Runde geholt", stufe: "profi", { schluessel: "m-ideen-aufgegriffen", titel: "Ideen aus dem Chat wurden aufgegriffen", stufe: "profi",
text: "Sie einmal namentlich ins Gespräch ziehen. Der Unterschied zwischen Zuschauen und Dabeisein." }, text: "Und gesagt, woher sie kamen. Wer sich wiederfindet, bringt die nächste Idee." },
{ schluessel: "m-eigene-idee-eingetragen", titel: "Eigene Idee ins Ideen-Board", stufe: "profi", { schluessel: "m-grund-teil-zu-sein", titel: "Es gab einen Grund, dabei zu sein", stufe: "profi",
text: "Was dir im Chat auffällt, ist die beste Quelle für Inhalte – aber nur, wenn es aufgeschrieben wird." }, text: "Etwas, das man nur mitbekommt, wenn man da ist. Sonst reicht der Clip am nächsten Tag." },
{ schluessel: "m-aktion-durchgezogen", titel: "Eine Aktion von Anfang bis Ende begleitet", stufe: "meister", { schluessel: "m-aus-aktion-wurde-reihe", titel: "Aus einer Aktion wurde eine Reihe", stufe: "meister",
text: "Ankündigen, durchführen, hinterher sagen, was daraus wurde. Der letzte Teil wird fast immer vergessen." }, text: "Erst beim zweiten Mal sieht man, ob das Format trägt oder nur das Thema getragen hat." },
{ schluessel: "m-community-uebersetzt", titel: "Zwischen Community und Team übersetzt", stufe: "meister", { schluessel: "m-community-waechst-von-innen", titel: "Die Community wächst von innen", 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." }, text: "Neue kommen, weil jemand sie mitgebracht hat. Das ist die einzige Reichweite, die bleibt." },
], ],
konflikt: [ konflikt: [
{ schluessel: "m-erst-lesen", titel: "Erst lesen, dann handeln", stufe: "anfaenger", { schluessel: "m-frueh-eingegriffen", titel: "Es wurde früh eingegriffen", stufe: "anfaenger",
text: "Zwei Sätze zurückscrollen. Die Hälfte der Streitfälle sieht mit dem Satz davor anders aus." }, text: "Bevor zehn Leute geantwortet haben. Danach moderiert man nicht mehr, man räumt auf." },
{ schluessel: "m-nicht-mitstreiten", titel: "Nicht mitstreiten", stufe: "anfaenger", { schluessel: "m-ruhig-geblieben-live", titel: "Es blieb ruhig", stufe: "anfaenger",
text: "Du bist nicht Partei. In dem Moment, in dem du eine wirst, hast du deine Rolle verloren." }, text: "Der Ton oben wird zum Ton im Chat. Das ist der schwerste Punkt auf dieser Liste." },
{ schluessel: "m-kurz-und-klar", titel: "Kurz und klar sagen, was gilt", stufe: "anfaenger", { schluessel: "m-nicht-oeffentlich-ausdiskutiert", titel: "Nichts wurde öffentlich ausdiskutiert", stufe: "anfaenger",
text: "Ein Satz, keine Begründungskette. Je länger die Erklärung, desto mehr Angriffsfläche." }, text: "Im Chat gewinnt niemand. Kurz sagen, was gilt, und weiter." },
{ schluessel: "m-abkuehlen-lassen", titel: "Abkühlen lassen statt eskalieren", stufe: "fort", { schluessel: "m-entscheidung-begruendet", titel: "Die Entscheidung war begründet", stufe: "fort",
text: "Stummschalten auf Zeit ist oft besser als sperren. Man kann es zurücknehmen." }, text: "In einem Satz, nicht in einer Kette. Je länger die Erklärung, desto mehr Angriffsfläche." },
{ schluessel: "m-zweite-meinung", titel: "Zweite Meinung geholt", stufe: "fort", { schluessel: "m-rueckendeckung-im-fall", titel: "Es gab Rückendeckung", stufe: "fort",
text: "Bei allem, was größer ist als ein blöder Kommentar. Zu zweit entscheidet es sich leichter und besser." }, text: "Wer eingreift, muss wissen, dass er danach nicht allein dasteht." },
{ schluessel: "m-belege-gesichert", titel: "Belege gesichert", stufe: "fort", { schluessel: "m-belege-gesichert-fall", titel: "Belege wurden gesichert", stufe: "fort",
text: "Bildschirmfoto, bevor der Beitrag weg ist. Ohne Beleg steht später Aussage gegen Aussage." }, 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", { schluessel: "m-danach-besprochen", titel: "Danach wurde besprochen", stufe: "fort",
text: "Auch wenn es gut ausging. DogFather und die rechte Hand sollten es von dir erfahren, nicht von anderen." }, text: "Auch wenn es gut ausging. Sonst lernt nur der daraus, der dabei war." },
{ schluessel: "m-keine-drohungen", titel: "Keine Drohungen, nur Folgen", stufe: "profi", { schluessel: "m-keine-ausnahmen", titel: "Keine Ausnahmen für Bekannte", stufe: "profi",
text: "\"Noch einmal und du bist raus\" ist eine Ansage. Alles darüber hinaus ist Streit." }, text: "Das ist der Punkt, an dem Regeln ihre Glaubwürdigkeit verlieren – und er fällt allen auf." },
{ schluessel: "m-nach-der-sperre", titel: "Nach der Sperre nicht nachtreten", stufe: "profi", { schluessel: "m-fehler-zurueckgenommen", titel: "Fehler wurden zurückgenommen", stufe: "profi",
text: "Kein Kommentar hinterher, kein Witz darüber. Der Chat merkt sich das." }, text: "Falsch gesperrt? Einmal sagen und rückgängig machen. Das kostet weniger als recht zu behalten." },
{ schluessel: "m-eigene-fehler", titel: "Eigenen Fehler zugegeben", stufe: "profi", { schluessel: "m-ton-blieb", titel: "Der Ton blieb auch danach", stufe: "profi",
text: "Falsch gesperrt? Zurücknehmen und einmal sagen. Das kostet weniger als der Versuch, recht zu behalten." }, text: "Kein Nachtreten, kein Witz über den Gesperrten. Der Chat merkt sich das." },
{ schluessel: "m-ernstfall-geprobt", titel: "Ernstfall einmal durchgesprochen", stufe: "meister", { schluessel: "m-fall-fuehrte-zu-regel", titel: "Der Fall führte zu einer Regel", stufe: "meister",
text: "Wer tut was, wenn es wirklich kippt. Im Ernstfall zum ersten Mal darüber nachzudenken ist zu spät." }, text: "Wenn zweimal improvisiert werden musste, fehlt eine Regel – nicht Erfahrung." },
{ schluessel: "m-daraus-gelernt", titel: "Aus dem Fall eine Regel gemacht", stufe: "meister", { schluessel: "m-kam-nicht-wieder", titel: "Es kam nicht wieder", stufe: "meister",
text: "Ein Vorfall, aus dem nichts folgt, kommt wieder. Der Ertrag ist die Regel, nicht die Sperre." }, text: "Der einzige Beweis, dass die Regel gewirkt hat." },
], ],
}; };
export const MODI_TECHNIK_PUNKTE = { export const MODI_TECHNIK_PUNKTE = {
setup: [ setup: [
{ schluessel: "m-zugaenge-funktionieren", titel: "Zugänge funktionieren", stufe: "anfaenger", { schluessel: "m-ton-ohne-rauschen", titel: "Ton ohne Rauschen", stufe: "anfaenger",
text: "Einmal vor der Sendung anmelden, nicht währenddessen. Ein abgelaufener Zugang fällt sonst im schlechtesten Moment auf." }, text: "Du hörst es wie die Zuschauer. Schlechter Ton ist der Grund Nummer eins, warum Leute wieder gehen." },
{ schluessel: "m-benachrichtigungen-an", titel: "Benachrichtigungen an", stufe: "anfaenger", { schluessel: "m-ton-nicht-uebersteuert", titel: "Ton nicht übersteuert", stufe: "anfaenger",
text: "Für Aufgaben und Team-Nachrichten. Ein Hinweis, den niemand bekommt, ist keiner." }, text: "Kratzt es bei lauten Stellen? Das merkt man erst, wenn jemand lacht – und dann ist es unangenehm laut." },
{ schluessel: "m-zweites-geraet", titel: "Zweites Gerät griffbereit", stufe: "anfaenger", { schluessel: "m-bild-scharf", titel: "Das Bild war scharf", stufe: "anfaenger",
text: "Handy neben dem Rechner. Wenn eines hängt, moderierst du mit dem anderen weiter." }, text: "Nicht weich, nicht verpixelt. Auf dem Handy fällt beides stärker auf als am Rechner." },
{ schluessel: "m-verbindung-stabil", titel: "Verbindung stabil", stufe: "anfaenger", { schluessel: "m-licht-ausreichend", titel: "Das Licht reichte", stufe: "anfaenger",
text: "Kabel schlägt WLAN. Ein Modi, der alle zehn Minuten neu lädt, sieht die Hälfte nicht." }, text: "Gesicht heller als der Hintergrund, kein Fenster im Rücken." },
{ schluessel: "m-chatfenster-eingerichtet", titel: "Chatfenster richtig eingerichtet", stufe: "fort", { schluessel: "m-verbindung-stabil-live", titel: "Die Verbindung war stabil", stufe: "anfaenger",
text: "Groß genug, ohne anderes darüber. Wer scrollen muss, um mitzulesen, liest nicht mit." }, text: "Wenn es bei dir hängt, hängt es bei vielen. Uhrzeit notieren – daran erkennt man die Ursache." },
{ schluessel: "m-werkzeuge-kennen", titel: "Moderations-Werkzeuge kennen", stufe: "fort", { schluessel: "m-szenenwechsel-sauber", titel: "Szenenwechsel waren sauber", stufe: "fort",
text: "Wo Löschen, Stummschalten, Sperren sitzt – ohne zu suchen. Im Ernstfall zählen Sekunden." }, text: "Kein schwarzes Bild dazwischen, kein Ton, der wegbricht." },
{ schluessel: "m-automod-verstanden", titel: "Automatische Filter verstanden", stufe: "fort", { schluessel: "m-musik-nicht-zu-laut", titel: "Die Musik war nicht zu laut", stufe: "fort",
text: "Was sie wegnehmen und was nicht. Wer sich darauf verlässt, ohne es zu wissen, übersieht genau die Lücke." }, text: "Sie darf die Stimme nicht überdecken. Am Rechner klingt das anders als im Kopfhörer." },
{ schluessel: "m-app-installiert", titel: "Die App wirklich installiert", stufe: "fort", { schluessel: "m-overlays-lesbar", titel: "Einblendungen waren lesbar", stufe: "fort",
text: "Nicht als Lesezeichen. Nur so kommen Erinnerungen an, auch wenn der Browser zu ist." }, text: "Auch auf einem Handy. Was du zusammenkneifen musst, liest niemand." },
{ schluessel: "m-tastenkuerzel", titel: "Tastenkürzel eingerichtet", stufe: "profi", { schluessel: "m-text-auf-handy", titel: "Text war auf dem Handy lesbar", stufe: "fort",
text: "Die drei, die du am häufigsten brauchst. Aus zwei Sekunden werden über einen Abend Minuten." }, text: "Der größte Teil schaut auf dem Handy. Was dort nicht ankommt, kommt gar nicht an." },
{ schluessel: "m-ruhe-eingestellt", titel: "Ruhezeiten eingestellt", stufe: "profi", { schluessel: "m-kein-nachhall", titel: "Kein Hall im Raum", stufe: "profi",
text: "Damit die App nachts still ist. Wer um drei geweckt wird, schaltet sie irgendwann ganz ab." }, text: "Klingt es nach Badezimmer, hört man nach zehn Minuten weg – ohne zu wissen, warum." },
{ schluessel: "m-ersatzweg", titel: "Ersatzweg ins Netz", stufe: "meister", { schluessel: "m-ausweichweg-vorhanden", titel: "Ein Ausweichweg war vorhanden", stufe: "meister",
text: "Mobilfunk, einmal wirklich benutzt. Ein Ersatz, den man erst im Ausfall einrichtet, ist keiner." }, text: "Zweites Gerät, zweite Leitung. Ein Ersatz, den man erst im Ausfall einrichtet, ist keiner." },
], ],
problem: [ problem: [
{ schluessel: "m-was-genau-faellt-aus", titel: "Was genau fällt aus?", stufe: "anfaenger", { schluessel: "m-ausfall-bemerkt", titel: "Der Ausfall wurde bemerkt", stufe: "anfaenger",
text: "Chat, Bild, dein Zugang oder das ganze Netz – das sind vier verschiedene Probleme mit vier Lösungen." }, text: "Und zwar oben, nicht nur im Chat. Minutenlang ins Leere zu senden ist das Schlimmste, was passieren kann." },
{ schluessel: "m-sofort-gesagt", titel: "Sofort gesagt, dass etwas ist", stufe: "anfaenger", { schluessel: "m-chat-informiert", titel: "Der Chat wurde informiert", stufe: "anfaenger",
text: "Lieber einmal zu viel im Team-Chat. Ein stiller Modi sieht aus wie ein anwesender." }, text: "Ein Satz genügt. Stille sieht aus wie Absturz, und dann gehen die Leute." },
{ schluessel: "m-notiert-solange-frisch", titel: "Notiert, solange es frisch ist", stufe: "anfaenger", { schluessel: "m-ansage-wie-lange", titel: "Es gab eine Ansage, wie lange", stufe: "fort",
text: "Uhrzeit, Fehlermeldung, was du zuletzt getan hast. Am nächsten Tag fehlt genau das." }, text: "Auch „ich weiß es nicht“ ist eine Ansage. Wer keine Zeit genannt bekommt, wartet nicht." },
{ schluessel: "m-eine-aenderung", titel: "Nur eine Änderung auf einmal", stufe: "fort", { schluessel: "m-jemand-uebernahm", titel: "Jemand hat übernommen", stufe: "fort",
text: "Wer drei Dinge gleichzeitig ändert und es geht wieder, weiß nicht warum – und steht beim nächsten Mal am Anfang." }, text: "Chat weiterführen, während oben repariert wird. Ohne das zerfällt die Runde in fünf Minuten." },
{ schluessel: "m-uebergeben-statt-kaempfen", titel: "Übergeben statt kämpfen", stufe: "fort", { schluessel: "m-fehler-festgehalten", titel: "Der Fehler wurde festgehalten", stufe: "fort",
text: "Nach fünf Minuten Basteln moderiert jemand anderes. Der Chat wartet nicht auf deine Technik." }, text: "Uhrzeit, Meldung, was zuletzt anders war. Am nächsten Tag fehlt genau das." },
{ schluessel: "m-bildschirmfoto", titel: "Bildschirmfoto gemacht", stufe: "fort", { schluessel: "m-nicht-live-gebastelt", titel: "Es wurde nicht live gebastelt", stufe: "profi",
text: "Eine Fehlermeldung, die man nachher beschreibt, ist eine andere als die, die dastand." }, text: "Nach zwei Minuten Herumprobieren schaut man dabei zu, wie jemand arbeitet. Dafür ist niemand gekommen." },
{ schluessel: "m-wiederholbar", titel: "Geprüft, ob es wiederkommt", stufe: "profi", { schluessel: "m-ausfall-wiederholte-sich-nicht", titel: "Der Ausfall kam nicht wieder", stufe: "profi",
text: "Einmal weg ist nicht gelöst. Beim nächsten Mal nachsehen gehört dazu." }, 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", { schluessel: "m-plan-b", titel: "Es gab einen Plan B", stufe: "meister",
text: "Nach zwanzig Minuten jemanden dazuholen. Zu zweit findet man es in fünf." }, text: "Und er wurde vorher einmal geprobt. Ein ungeprobter Ausweichweg ist keiner." },
], ],
loesung: [ loesung: [
{ schluessel: "m-was-geholfen-hat", titel: "Was geholfen hat", stufe: "anfaenger", { schluessel: "m-loesung-aufgeschrieben", titel: "Die Lösung wurde aufgeschrieben", stufe: "anfaenger",
text: "Genau die eine Änderung – nicht alles, was probiert wurde. Sonst ist es beim nächsten Mal wieder Raten." }, text: "Genau die eine Änderung – nicht alles, was probiert wurde." },
{ schluessel: "m-fuer-alle-aufgeschrieben", titel: "Für alle aufgeschrieben", stufe: "anfaenger", { schluessel: "m-loesung-verstaendlich", titel: "Sie war verständlich", stufe: "fort",
text: "In die Bibliothek, nicht in den Chat. Was im Chat steht, ist nach zwei Tagen unauffindbar." }, text: "Auch für jemanden, der nicht dabei war. Was man selbst geschrieben hat, versteht man immer." },
{ schluessel: "m-anleitung-verstaendlich", titel: "Anleitung verständlich", stufe: "fort", { schluessel: "m-loesung-hielt", titel: "Sie hat gehalten", stufe: "fort",
text: "Jemanden gegenlesen lassen, der es nicht erlebt hat. Was du selbst geschrieben hast, verstehst du immer." }, text: "Beim nächsten Mal nachgesehen. Ein Pflaster ist in Ordnung – aber es gehört dabeigeschrieben." },
{ schluessel: "m-dauerhaft-oder-pflaster", titel: "Dauerhaft oder Pflaster?", stufe: "fort", { schluessel: "m-loesung-geteilt", titel: "Sie wurde geteilt", stufe: "profi",
text: "Beides ist in Ordnung – aber es gehört dabeigeschrieben, damit niemand darauf baut." }, text: "In der Bibliothek, nicht im Chat. Was im Chat steht, ist nach zwei Tagen unauffindbar." },
{ schluessel: "m-vorbeugen-vorgeschlagen", titel: "Vorbeugen vorgeschlagen", stufe: "profi", { schluessel: "m-vorbeugung", titel: "Es wurde vorgebeugt", stufe: "profi",
text: "Wenn es zweimal passiert ist, ist es kein Zufall mehr, sondern eine fehlende Einstellung." }, text: "Wenn es zweimal passiert ist, fehlt eine Einstellung – nicht Glück." },
{ schluessel: "m-wissen-weitergegeben", titel: "Wissen weitergegeben", stufe: "meister", { schluessel: "m-wissen-nicht-bei-einem", titel: "Das Wissen liegt nicht bei einer Person", 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." }, text: "Wer als Einziger etwas kann, ist ein Engpass – und darf nicht krank werden." },
], ],
}; };
/* 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 = { export const MODI_PUNKTE = {
live: MODI_LIVE_PUNKTE, live: MODI_LIVE_PUNKTE,
community: MODI_COMMUNITY_PUNKTE, community: MODI_COMMUNITY_PUNKTE,
+266 -2
View File
@@ -26,10 +26,33 @@
===================================================================== */ ===================================================================== */
import express from "express"; import express from "express";
import { import {
db, sitzungLesen, istDogFather, heuteLokal, db, sitzungLesen, istDogFather, heuteLokal, protokolliere, echteIp,
} from "./workspace.js"; } from "./workspace.js";
import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; import { MODI_PUNKTE } from "./workspace-modi-punkte.js";
/* Titel und Bereich zu einem Schluessel -- damit im Eingang steht,
WORUM es geht, und nicht nur ein Schluessel. */
const PUNKT = new Map();
for (const [bereich, gruppen] of Object.entries(MODI_PUNKTE)) {
for (const punkte of Object.values(gruppen)) {
for (const p of punkte) PUNKT.set(`${bereich}|${p.schluessel}`, p);
}
}
/* Die Kategorie, unter der eine daraus entstandene Aufgabe einsortiert
wird. Der Bereich sagt es schon -- eine zweite Zuordnung waere eine
zweite Gelegenheit, dass beide auseinanderlaufen. */
const KATEGORIE = { live: "technik", community: "community", technik: "technik" };
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
export const teamlageRouter = express.Router(); export const teamlageRouter = express.Router();
/* Wie viele Punkte es je Bereich ueberhaupt gibt. Einmal gerechnet -- /* Wie viele Punkte es je Bereich ueberhaupt gibt. Einmal gerechnet --
@@ -49,7 +72,11 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
WHERE rolle = 'modi' AND aktiv = 1 ORDER BY name`).all(); WHERE rolle = 'modi' AND aktiv = 1 ORDER BY name`).all();
if (!modis.length) { if (!modis.length) {
return res.json({ modis: [], bereiche: GESAMT, heute: heuteLokal() }); /* `eingang` auch hier -- die Oberflaeche darf nicht raten
muessen, ob das Feld fehlt oder leer ist. */
return res.json({
modis: [], eingang: [], angebote: [], bereiche: GESAMT, heute: heuteLokal(),
});
} }
const platz = modis.map(() => "?").join(","); const platz = modis.map(() => "?").join(",");
const nummern = modis.map((m) => m.id); const nummern = modis.map((m) => m.id);
@@ -88,6 +115,46 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
ideen.set(z.p, z.n); ideen.set(z.p, z.n);
} }
/* =================================================================
DER EINGANG: was die Modis gemeldet haben (10.09.2026)
Filipes Entscheidung: erst ein Vorschlag, dann eine Aufgabe.
Hier steht, was noch nicht behandelt ist -- offen heisst: Ihr
habt noch nicht darauf geantwortet. Was zur Aufgabe wurde oder
begruendet abgelehnt ist, faellt heraus; ein Eingang, der nie
leer wird, wird nicht mehr angesehen. */
const eingang = [];
for (const z of db().prepare(
`SELECT s.bereich, s.schluessel, s.creator_id, s.begruendung, s.am, p.name AS von_name
FROM punkt_stand s LEFT JOIN personen p ON p.id = s.von
WHERE s.creator_id IN (${platz})
AND s.stufe = 'verbessern' AND s.behandelt_am IS NULL
ORDER BY s.am DESC`).all(...nummern)) {
const punkt = PUNKT.get(`${z.bereich}|${z.schluessel}`);
/* Ein Punkt, den es nicht mehr gibt, wird uebersprungen -- aber
gezaehlt waere er sonst und niemand koennte ihn behandeln. */
if (!punkt) continue;
eingang.push({
bereich: z.bereich, schluessel: z.schluessel, person_id: z.creator_id,
titel: punkt.titel, hinweis: punkt.text,
begruendung: z.begruendung || "", am: z.am, von: z.von_name || null,
});
}
/* =================================================================
DIE ANGEBOTE (10.09.2026)
Vorschlaege der Modis, ueber die hier entschieden wird. Sie
stehen im selben Eingang wie die Rueckmeldungen -- EIN Ort, an
dem etwas auf eine Antwort wartet, nicht zwei. Wer zwei Eingaenge
hat, sieht irgendwann in den falschen. */
const angebote = db().prepare(
`SELECT e.id, e.art, e.titel, e.text, e.einsatz, e.bewertung, e.dringlichkeit,
e.datum, e.erstellt_von, p.name AS von_name
FROM eintraege e LEFT JOIN personen p ON p.id = e.erstellt_von
WHERE e.bereich = 'angebote' AND e.status = 'offen'
ORDER BY e.bewertung DESC, e.datum DESC`).all();
/* Wie viele Rueckmeldungen an seinen Punkten haengen -- das ist /* Wie viele Rueckmeldungen an seinen Punkten haengen -- das ist
das Gespraech, nicht die Note. */ das Gespraech, nicht die Note. */
const nachrichten = new Map(); const nachrichten = new Map();
@@ -100,6 +167,8 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
res.json({ res.json({
heute, heute,
bereiche: GESAMT, bereiche: GESAMT,
eingang,
angebote,
modis: modis.map((m) => { modis: modis.map((m) => {
const s = stand.get(m.id) || {}; const s = stand.get(m.id) || {};
const je = {}; const je = {};
@@ -125,3 +194,198 @@ teamlageRouter.get("/workspace/api/teamlage", (req, res) => {
res.status(503).json({ fehler: "nicht_verfuegbar" }); res.status(503).json({ fehler: "nicht_verfuegbar" });
} }
}); });
/* =====================================================================
EINE MELDUNG BEHANDELN (10.09.2026)
Filipes Entscheidung: *"Erst ein Vorschlag, den du annimmst."* Zwei
Antworten, und beide kommen beim Modi an:
annehmen -> daraus wird eine Aufgabe (fuer den, der drueckt)
ablehnen -> mit Begruendung, ohne dass etwas entsteht
WARUM BEIDE EINE NACHRICHT SCHREIBEN: Eine Meldung, auf die nichts
folgt, sieht fuer den Modi aus wie eine, die niemand gelesen hat --
und beim dritten Mal meldet er nichts mehr. Die Nachricht haengt am
Punkt (punkt_nachricht), also genau dort, wo er sie beim naechsten
Mal wiederfindet.
DIE AUFGABE GEHOERT DEM, DER DRUECKT. Filipe oder VanVan -- wer
annimmt, nimmt sie sich. Sie einem Dritten zuzuweisen waere eine
Entscheidung ueber jemand anderen, ohne ihn zu fragen.
===================================================================== */
teamlageRouter.post("/workspace/api/teamlage/behandeln", gleicheHerkunft,
express.json({ limit: "16kb" }), (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const bereich = String(req.body?.bereich || "");
const schluessel = String(req.body?.schluessel || "");
const personId = Number(req.body?.person_id) || 0;
const art = String(req.body?.art || "");
const grund = String(req.body?.grund ?? "").trim().slice(0, 2000);
if (!["aufgabe", "abgelehnt"].includes(art)) {
return res.status(400).json({ fehler: "Unbekannte Art." });
}
if (!PUNKT.has(`${bereich}|${schluessel}`) || !personId) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* EINE ABSAGE OHNE GRUND IST KEINE ANTWORT. Wer nichts dazu
schreibt, laesst den Modi genauso ratlos zurueck wie
Schweigen -- nur mit dem Unterschied, dass die Meldung jetzt
auch noch aus dem Eingang verschwunden ist. */
if (art === "abgelehnt" && grund.length < 3) {
return res.status(400).json({ fehler: "Bitte kurz sagen, warum nicht." });
}
const meldung = db().prepare(
`SELECT begruendung FROM punkt_stand
WHERE bereich = ? AND schluessel = ? AND creator_id = ?
AND stufe = 'verbessern' AND behandelt_am IS NULL`)
.get(bereich, schluessel, personId);
/* Schon behandelt oder gar nicht gemeldet -- beides ist "gibt es
hier nicht". Zwei Antworten waeren zwei Faelle, die die
Oberflaeche unterscheiden muesste, ohne dass es hilft. */
if (!meldung) return res.status(404).json({ fehler: "nicht_gefunden" });
const punkt = PUNKT.get(`${bereich}|${schluessel}`);
const jetzt = new Date().toISOString();
let aufgabeId = null;
if (art === "aufgabe") {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,?,?,?)`).run(
punkt.titel,
/* Der Satz des Modis steht IN der Aufgabe -- ohne ihn muesste
man zurueckblaettern, um zu wissen, worum es ging. */
meldung.begruendung
? `Gemeldet aus „${punkt.titel}“:
${meldung.begruendung}`
: `Gemeldet aus „${punkt.titel}“.`,
person.id, KATEGORIE[bereich] || "sonstiges", jetzt, person.id);
aufgabeId = Number(lastInsertRowid);
}
db().prepare(
`UPDATE punkt_stand
SET behandelt_am = ?, behandelt_von = ?, behandelt_art = ?, aufgabe_id = ?
WHERE bereich = ? AND schluessel = ? AND creator_id = ?`)
.run(jetzt, person.id, art, aufgabeId, bereich, schluessel, personId);
db().prepare(`INSERT INTO punkt_nachricht
(bereich, schluessel, creator_id, text, von, am) VALUES (?,?,?,?,?,?)`)
.run(bereich, schluessel, personId,
art === "aufgabe"
? `Danke – daraus ist eine Aufgabe geworden.${grund ? " " + grund : ""}`
: `Danke fürs Melden. Das machen wir nicht: ${grund}`,
person.id, jetzt);
protokolliere("meldung_behandelt", {
personId: person.id, rolle: person.rolle, ip: echteIp(req),
detail: `${bereich}/${schluessel} -> ${art}`.slice(0, 120),
});
res.json({ ok: true, art, aufgabe_id: aufgabeId });
} catch (fehler) {
console.error("[workspace] Meldung behandeln:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
UEBER EIN ANGEBOT ENTSCHEIDEN (10.09.2026)
Filipes Wahl: *"Angebote an dich zur Entscheidung ... Bei Annahme
entstehen die Aufgaben fuer alle Beteiligten auf einmal."*
BEI ANNAHME ZWEI AUFGABEN, nicht eine:
* der Modi, der es vorgeschlagen hat, setzt es um
* und wenn im Angebot steht, was DogFather dafuer tun muesste,
bekommt der, der annimmt, genau das als eigene Aufgabe
Die zweite ist der Kern von Filipes Wunsch ("entscheiden was ich
machen muss"). Ohne sie waere eine Annahme ein Nicken, aus dem nichts
folgt -- und beim dritten Mal schlaegt niemand mehr etwas vor.
ABGELEHNT BRAUCHT EINEN GRUND, wie bei den Rueckmeldungen: Eine
Absage ohne Begruendung ist fuer den, der sie bekommt, dasselbe wie
Schweigen -- nur endgueltiger.
===================================================================== */
teamlageRouter.post("/workspace/api/teamlage/angebot", gleicheHerkunft,
express.json({ limit: "16kb" }), (req, res) => {
try {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const id = Number(req.body?.id) || 0;
const art = String(req.body?.art || "");
const grund = String(req.body?.grund ?? "").trim().slice(0, 2000);
if (!["angenommen", "abgelehnt"].includes(art)) {
return res.status(400).json({ fehler: "Unbekannte Art." });
}
if (art === "abgelehnt" && grund.length < 3) {
return res.status(400).json({ fehler: "Bitte kurz sagen, warum nicht." });
}
const angebot = db().prepare(
`SELECT id, titel, text, einsatz, erstellt_von FROM eintraege
WHERE id = ? AND bereich = 'angebote' AND status = 'offen'`).get(id);
/* Schon entschieden oder gar keins -- beides ist "gibt es hier
nicht". Zwei Antworten waeren zwei Faelle, die die Oberflaeche
unterscheiden muesste, ohne dass es hilft. */
if (!angebot) return res.status(404).json({ fehler: "nicht_gefunden" });
const jetzt = new Date().toISOString();
const angelegt = [];
if (art === "angenommen") {
if (angebot.erstellt_von) {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,'planung',?,?)`).run(
`Umsetzen: ${angebot.titel}`, angebot.text || null,
angebot.erstellt_von, jetzt, person.id);
angelegt.push({ fuer: angebot.erstellt_von, id: Number(lastInsertRowid) });
}
if (angebot.einsatz) {
const { lastInsertRowid } = db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id,
kategorie, erstellt, erstellt_von)
VALUES (?,?,'offen','mittel',?,'planung',?,?)`).run(
angebot.einsatz, `Aus dem Angebot „${angebot.titel}“.`,
person.id, jetzt, person.id);
angelegt.push({ fuer: person.id, id: Number(lastInsertRowid) });
}
}
db().prepare(
"UPDATE eintraege SET status = ?, geaendert = ? WHERE id = ?")
.run(art, jetzt, angebot.id);
/* Die Antwort gehoert an das Angebot, damit der Modi sie dort
wiederfindet, wo er nachsieht. `text` ist das einzige Feld
dafuer -- angehaengt, nicht ersetzt. */
const antwort = art === "angenommen"
? `
— Angenommen von ${person.name}${grund ? ": " + grund : "."}`
: `
— Nicht umgesetzt (${person.name}): ${grund}`;
db().prepare("UPDATE eintraege SET text = COALESCE(text, '') || ? WHERE id = ?")
.run(antwort, angebot.id);
protokolliere("angebot_entschieden", {
personId: person.id, rolle: person.rolle, ip: echteIp(req),
detail: `#${angebot.id} ${art}`.slice(0, 120),
});
res.json({ ok: true, art, aufgaben: angelegt.length });
} catch (fehler) {
console.error("[workspace] Angebot entscheiden:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
+91 -1
View File
@@ -27,6 +27,8 @@ import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { sitzungPasstZurAdresse, CREW_ADRESSE, istOhneModiAdresse, istCrewAdresse }
from "./crew-adresse.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -284,6 +286,9 @@ const MODI_BEREICHE = [
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst", { gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert", name: "Ideen-Board", unter: "Gesammelt und nach Dringlichkeit sortiert",
zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal" }, zeichen: "content", ton: 23, ziel: "bereich.html?b=ideen", szene: "portal" },
{ gruppe: "Täglich", gruppeUnter: "Was du sowieso jeden Tag aufmachst",
name: "Angebote", unter: "Vorschläge, über die DogFather entscheidet",
zeichen: "agentur", ton: 17, ziel: "bereich.html?b=angebote", szene: "halle" },
{ gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung", { gruppe: "Rund ums Live", gruppeUnter: "Vor, während und nach der Sendung",
name: "Live-Ablauf", unter: "Checkliste für vorher, mittendrin und danach", name: "Live-Ablauf", unter: "Checkliste für vorher, mittendrin und danach",
@@ -948,6 +953,17 @@ function umstellungen(d) {
workspace-aufgaben.js) -- an EINER Stelle, durch die alle workspace-aufgaben.js) -- an EINER Stelle, durch die alle
Schreibwege laufen. */ Schreibwege laufen. */
["aufgaben", "kategorie", "TEXT"], ["aufgaben", "kategorie", "TEXT"],
/* WAS DOGFATHER DAZU TUN MUESSTE (10.09.2026).
Filipe: *"auch entscheiden was ich machen muss wenn das und das
erledigt wird."* Der Modi schreibt es beim Angebot dazu; wird
das Angebot angenommen, wird genau daraus seine Aufgabe.
Ein eigenes Feld und nicht im Fliesstext: Aus einem Absatz laesst
sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint
war. */
["eintraege", "einsatz", "TEXT"],
]) { ]) {
try { try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -1498,6 +1514,18 @@ function umstellungen(d) {
waere die vierte Gelegenheit dazu. */ waere die vierte Gelegenheit dazu. */
checkListeErweitern(d, "eintraege", "bereich", "ideen", checkListeErweitern(d, "eintraege", "bereich", "ideen",
["live", "content", "technik", "community", "schutz", "agentur", "ideen"], jetztStempel); ["live", "content", "technik", "community", "schutz", "agentur", "ideen"], jetztStempel);
/* Die Angebote (10.09.2026) -- Vorschlaege der Modis, ueber die
DogFather und VanVan entscheiden. */
checkListeErweitern(d, "eintraege", "bereich", "angebote",
["live", "content", "technik", "community", "schutz", "agentur", "ideen", "angebote"],
jetztStempel);
/* ZWEI NEUE ZUSTAENDE. Bisher kannte ein Eintrag nur offen/erledigt.
Ein Angebot hat aber eine ENTSCHEIDUNG: angenommen oder abgelehnt
-- und das ist etwas anderes als "erledigt". Wer beides in einen
Topf wirft, kann hinterher nicht mehr sagen, was aus einem
Vorschlag geworden ist. */
checkListeErweitern(d, "eintraege", "status", "angenommen",
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
} }
export function db() { export function db() {
@@ -2371,6 +2399,24 @@ export function sitzungLesen(req) {
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
return null; return null;
} }
/* DIE ADRESSE ENTSCHEIDET MIT (10.09.2026).
Seit die Modi-App unter crew.dogfather-universe.com laeuft, gehoert
jede Sitzung zu genau EINER Adresse: Modis dorthin, alle anderen auf
den Workspace. Die Regel steht in crew-adresse.js und ist dort ohne
laufenden Server pruefbar.
WARUM HIER UND NICHT IN DEN MODULEN: Durch diese Funktion geht jedes
der 26 Fachmodule und jede Seitenschranke. Eine Middleware daneben
koennte man in einem neuen Modul vergessen -- und ein vergessener
Rechteschutz faellt nicht auf, weil dann alles geht.
`null` heisst hier dasselbe wie bei einer abgelaufenen Sitzung:
nicht angemeldet. Die Seitenschranke schickt zur Zugangswand, die
Schnittstellen antworten mit 401. Beides ist genau richtig -- auf
dieser Adresse ist diese Person schlicht niemand. */
if (!sitzungPasstZurAdresse(reihe.rolle, req.get?.("host"))) return null;
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle }; return { id: reihe.id, name: reihe.name, rolle: reihe.rolle };
} catch { return null; } } catch { return null; }
} }
@@ -2671,6 +2717,33 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */ WEN man pruefen soll -- danach entscheidet scrypt wie ueberall. */
const still = codeBrauchbar ? stillerZugang(code) : null; const still = codeBrauchbar ? stillerZugang(code) : null;
if (still) { if (still) {
/* AUF DER ALTEN ADRESSE GIBT ES KEINE SITZUNG MEHR (10.09.2026).
Die Modi-App hat seit heute eine eigene Adresse. Wer seinen Code
noch auf workspace.dogfather-universe.com eintippt, bekommt hier
den Weg dorthin -- und ausdruecklich KEINE Anmeldung: zwei
Sitzungen auf zwei Adressen waeren zwei Apps auf dem
Startbildschirm und eine zweite Spur genau dort, wo alle anderen
arbeiten.
DASS DIE ANTWORT DIE NEUE ADRESSE NENNT, IST KEIN LECK. Sie
kommt nur nach Eingabe eines gueltigen Modi-Codes; wer den hat,
ist Modi. Fuer jeden anderen -- auch fuer einen Manager mit
gueltigem eigenen Code -- veraendert sich hier gar nichts.
Kein Eintrag in `versuche`: Der Code war richtig. Ein Modi, der
die alte Verknuepfung auf dem Handy hat, wuerde sich sonst mit
jedem Versuch selbst aussperren. */
if (istOhneModiAdresse(req.get("host"))) {
protokolliere("anmeldung_alte_adresse", {
personId: still.id, rolle: still.rolle, ip, detail: still.name,
});
return res.json({
weiter: `${CREW_ADRESSE}/workspace/start.html`,
name: still.name, rolle: still.rolle, umgezogen: true,
});
}
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip); db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id); db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
sitzungSetzen(res, still, req); sitzungSetzen(res, still, req);
@@ -2685,7 +2758,24 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
/* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine /* Ab hier der gewohnte Weg -- unveraendert. 'modi' ist keine
Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine Kachel-Rolle und faellt deshalb in dieselbe Antwort wie eine
erfundene. */ erfundene. */
if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar) { /* AUF DER MODI-ADRESSE ENDET DER GEWOHNTE WEG HIER (10.09.2026).
crew.dogfather-universe.com ist nicht die zweite Tuer in den
Workspace. Wer sie errät und seinen eigenen Code eintippt, soll
genau das erleben, was er bei einem Tippfehler erlebt: Die
Zugangswand sieht aus wie immer, der Code stimmt nicht. Wuerde er
stattdessen hereinkommen, wuesste er im selben Moment, dass es
hier noch etwas gibt.
BEWUSST IN DIESELBE BEDINGUNG GESCHRIEBEN und nicht als eigener
Block davor: So ist es nicht nur dieselbe ANTWORT, sondern
derselbe Weg -- ein Eintrag in `versuche`, kein scrypt-Durchlauf,
401 "ungueltig". Ein eigener Block waere messbar schneller oder
langsamer gewesen als der Weg daneben, und genau daran erkennt
man eine Sonderbehandlung. */
const fremdAufCrew = istCrewAdresse(req.get("host"));
if (!ROLLEN_KACHEL.has(rolle) || !codeBrauchbar || fremdAufCrew) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
return res.status(401).json({ fehler: "ungueltig" }); return res.status(401).json({ fehler: "ungueltig" });
} }
+260
View File
@@ -0,0 +1,260 @@
/* ===================================================================
Das App-Symbol der Modi-App ("Team Dogi", 10.09.2026).
DER HUSKY IST DERSELBE. Filipe am 09.09.2026 zum Rollenbild:
"das modi symbol soll das gleiche sein wie bei dogfather." Das gilt
hier genauso -- es ist dieselbe Vorlage, dieselbe Technik.
DIE CHILI IST ES NICHT, UND ZWAR ABSICHTLICH. Sie steht fuer Spicy
Media. Die Modis gehoeren nicht dorthin; ihre Marke heisst "Team
Dogi". Eine Chili auf ihrem Startbildschirm waere kein Schmuck,
sondern eine falsche Aussage. An ihre Stelle tritt der Ton, der auch
ihre Kacheln traegt: das gedeckte Stahlblau #5f8a9f.
WARUM EINE EIGENE DATEI UND KEIN SCHALTER IN workspace-symbol.mjs:
Jenes Werkzeug PRUEFT, dass die Chili in jeder Fassung sichtbar ist.
Ein Schalter haette diese Pruefung fuer den halben Aufruf abschalten
muessen -- und eine Pruefung, die man abschalten kann, ist bald
abgeschaltet. Zwei Bilder mit zwei Regeln bekommen zwei Werkzeuge,
die jeweils ihre eigenen Regeln durchsetzen.
DIE DRITTE GEGENPROBE, DIE ES DORT NICHT GIBT: Am Ende wird das
erzeugte Symbol gegen das des Workspace gehalten. Waeren beide
gleich, haette der ganze Umzug seinen Sinn verloren -- zwei Apps mit
demselben Zeichen sind auf einem Startbildschirm dasselbe Symbol
zweimal. Gemessen wird das, nicht behauptet.
Aufrufen mit: node tools/crew-symbol.mjs
=================================================================== */
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), "..");
const HUSKY = join(WURZEL, "workspace", "assets", "img", "marke-husky.webp");
/* Umlenkbar wie beim Schwesterwerkzeug: Ein Symbol beurteilt man, indem
man es ansieht -- und dafuer darf man die echten Dateien nicht bei
jedem Versuch ueberschreiben. */
const ZIEL = process.env.SYMBOL_ZIEL || join(WURZEL, "assets", "img", "app-symbole");
/* Zum Vergleichen -- immer die echten Symbole, auch wenn das Ziel
umgelenkt ist. Fehlt eines, ist die dritte Gegenprobe NICHT
durchfuehrbar, und das ist ein Fehler, kein Ueberspringen. */
const VERGLEICH = join(WURZEL, "assets", "img", "app-symbole");
if (!existsSync(HUSKY)) { console.error(`Fehlt: ${HUSKY}`); process.exit(1); }
const alsDaten = (p) => "data:image/webp;base64," + readFileSync(p).toString("base64");
const GROESSEN = [
{ n: 512, endung: "512" },
{ n: 192, endung: "192" },
{ n: 512, endung: "512-maskable", maskable: true },
{ n: 192, endung: "192-maskable", maskable: true },
{ n: 180, endung: "180" },
{ n: 32, endung: "32" },
];
function html(n, maskable, husky, weglassen = "") {
/* KLEIN = alles unter 64 px. Dort faellt die Gesichtszeichnung weg:
achtfach vergroessert verglichen ist der Kopf mit Zeichnung bei
32 px ein dunkler Fleck, als reine Silhouette ein Hund. Dieselbe
Messung wie beim Workspace-Symbol, dasselbe Ergebnis. */
const klein = n < 64;
const rand = Math.round(n * 0.22);
/* ALLES IN EINER GRUPPE, damit fuer die maskable-Fassung genau EINE
Zahl kleiner wird und nicht mehrere auseinanderlaufen. 0,74 haelt
alles im 80-%-Sicherheitskreis, den Android als einziges garantiert.
Ohne Chili darf der Kopf etwas groesser stehen -- er ist hier allein
und muss die Flaeche selbst tragen. */
const g = Math.round(n * (maskable ? 0.74 : 0.96));
const ecke = maskable ? "" : `border-radius:${rand}px;`;
return `<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;width:${n}px;height:${n}px;overflow:hidden}
.feld{position:relative;width:${n}px;height:${n}px;${ecke}
/* DER GRUND. Dieselbe Nacht wie beim Workspace -- es ist dasselbe
Haus --, aber das warme Licht unten ist durch den Modi-Ton
ersetzt. Beide Lichter sind damit kalt, das Zeichen wirkt
ruhiger und ist trotzdem sofort ein anderes.
Gedeckt, nicht leuchtend: augenschonend gilt auch fuer ein
Symbol, das auf einem hellen Startbildschirm liegt. */
background:
radial-gradient(76% 56% at 50% 24%, rgba(95,189,255,.20) 0%, transparent 68%),
radial-gradient(90% 56% at 50% 104%, rgba(95,138,159,.46) 0%, transparent 70%),
linear-gradient(180deg,#1a2f47 0%,#0d1a29 52%,#060c14 100%);
box-shadow:inset 0 ${Math.max(1, Math.round(n * .004))}px 0 rgba(255,255,255,.24),
inset 0 0 ${Math.round(n * .06)}px rgba(0,0,0,.38);
display:grid;place-items:center}
.gruppe{position:relative;width:${g}px;height:${g}px}
.gruppe > i{position:absolute;left:50%;transform:translate(-50%,-50%)}
/* DER KOPF -- zwei Lagen, und der Grund dafuer ist teuer bezahlt:
Als blosse MASKE zaehlt nur der Alphakanal, und die Vorlage ist
rundum freigestellt. Uebrig bliebe eine geschlossene Flaeche in
Hundeform -- ohne Auge, ohne Schnauze, ohne Ohrinneres. Genau das
war am 09.09.2026 der Fehler beim Workspace-Symbol.
Jetzt unten die Silhouette, darueber die Originalzeichnung im
Mischmodus "screen": Screen laesst Schwarz stehen (das Fell
bleibt) und hebt Weiss heraus (Gesicht, Ohrinneres, Auge).
"mask-mode: luminance" kann Chromium zwar, aber eine
Kurzschreibweise in einer spezifischeren Regel setzt es
stillschweigend auf "alpha" zurueck -- der Mischmodus hat diese
Falle nicht.
DIE ZWEITE MASKE blendet den Hals nach unten aus. Die Vorlage ist
dort angeschnitten; als Symbol sah die gerade Kante nach Fehler
aus. Bei 32 px faellt der Auslauf weg, weil er dann gerade die
Schnauze frisst -- die Kante ist dort drei Bildpunkte hoch. */
.gruppe > i.kopf{width:${klein ? 78 : 90}%;height:${klein ? 78 : 90}%;
top:${klein ? 46 : 45}%;
z-index:2;isolation:isolate;
background:linear-gradient(162deg,#6f9fbe 0%,#3a6a91 44%,#14344f 100%);
${klein
? `-webkit-mask:url("${husky}") center/contain no-repeat;
mask:url("${husky}") center/contain no-repeat;`
: `-webkit-mask:url("${husky}") center/contain no-repeat,
linear-gradient(180deg,#000 58%, transparent 86%);
-webkit-mask-composite:source-in;
mask:url("${husky}") center/contain no-repeat,
linear-gradient(180deg,#000 58%, transparent 86%);
mask-composite:intersect;`}
filter:drop-shadow(0 ${Math.round(n * .006)}px ${Math.round(n * .016)}px rgba(0,0,0,.7))
drop-shadow(0 0 ${Math.round(n * .024)}px rgba(95,138,159,.44))}
.gruppe > i.kopf .zeichnung{position:absolute;inset:0;display:block;
background:url("${husky}") center/contain no-repeat;
mix-blend-mode:screen;
/* Ohne Daempfer wird das Weiss der Vorlage zu reinem Weiss und
blendet auf dunklem Grund. Hell genug zum Erkennen, nicht
heller. */
filter:brightness(.88)}
.gruppe > i.kopf.flach{background:linear-gradient(168deg,#ffffff 4%,#e2edf5 38%,#a8bfcd 100%)}
.gruppe > i.kopf.flach .zeichnung{display:none}
${weglassen === "zeichnung" ? ".gruppe > i.kopf .zeichnung{display:none}" : ""}
</style></head><body><div class="feld"><div class="gruppe">
<i class="kopf${klein ? " flach" : ""}"><b class="zeichnung"></b></i>
</div></div></body></html>`;
}
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const seite = await (await browser.newContext({ deviceScaleFactor: 1 })).newPage();
if (!existsSync(ZIEL)) mkdirSync(ZIEL, { recursive: true });
const husky = alsDaten(HUSKY);
let gebaut = 0;
const maengel = [];
async function aufnehmen(n, maskable, weglassen = "") {
await seite.setViewportSize({ width: n, height: n });
await seite.setContent(html(n, maskable, husky, weglassen), { waitUntil: "networkidle" });
return await seite.screenshot({ omitBackground: false });
}
/* Streuung der Helligkeit. Ein leeres Feld hat keine -- das beweist,
dass ueberhaupt etwas draufsteht, mehr nicht. Die Bytezahl beweist
auch das nicht: ein einfarbiges PNG ist genauso gross wie ein volles. */
async function streuungVon(bild) {
return await seite.evaluate(async (b64) => {
const b = new Image(); b.src = "data:image/png;base64," + b64; await b.decode();
const c = document.createElement("canvas");
c.width = b.width; c.height = b.height;
const g2 = c.getContext("2d"); g2.drawImage(b, 0, 0);
const d = g2.getImageData(0, 0, c.width, c.height).data;
let s = 0, s2 = 0, m = 0;
for (let i = 0; i < d.length; i += 4) {
const h = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
s += h; s2 += h * h; m++;
}
return Math.sqrt(s2 / m - (s / m) ** 2);
}, bild.toString("base64"));
}
/* Anteil der Bildpunkte, die sich sichtbar unterscheiden. Acht Stufen
Abstand, damit Kantenglaettung allein den Wert nicht hochtreibt.
-1 heisst "nicht vergleichbar" (verschiedene Groessen) und ist
ausdruecklich etwas anderes als 0. */
async function unterschied(a, b) {
return await seite.evaluate(async ([x, y]) => {
const laden = async (b64) => {
const b = new Image(); b.src = "data:image/png;base64," + b64; await b.decode();
const c = document.createElement("canvas");
c.width = b.width; c.height = b.height;
c.getContext("2d").drawImage(b, 0, 0);
return c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
};
const [p, q] = [await laden(x), await laden(y)];
if (p.length !== q.length) return -1;
let anders = 0, m = 0;
for (let i = 0; i < p.length; i += 4) {
const w = Math.max(Math.abs(p[i] - q[i]), Math.abs(p[i + 1] - q[i + 1]),
Math.abs(p[i + 2] - q[i + 2]), Math.abs(p[i + 3] - q[i + 3]));
if (w > 8) anders++;
m++;
}
return anders / m;
}, [a.toString("base64"), b.toString("base64")]);
}
let verglichen = 0;
for (const g of GROESSEN) {
const bild = await aufnehmen(g.n, !!g.maskable);
writeFileSync(join(ZIEL, `crew-${g.endung}.png`), bild);
gebaut++;
const streuung = await streuungVon(bild);
if (streuung < 12) maengel.push(`${g.endung}: kaum Zeichnung (Streuung ${streuung.toFixed(1)})`);
const klein = g.n < 64;
const ohneZeichnung = await unterschied(bild, await aufnehmen(g.n, !!g.maskable, "zeichnung"));
if (klein) {
/* Unter 64 px ist die schlichte Silhouette Absicht. Geprueft wird,
dass sie es auch bleibt -- sonst waere die Regel bloss ein
Kommentar, der beim naechsten Umbau stillschweigend kippt. */
if (ohneZeichnung > 0.001) maengel.push(`${g.endung}: klein soll ohne Gesichtszeichnung sein`);
} else if (ohneZeichnung < 0.04) {
maengel.push(`${g.endung}: das Gesicht kommt nicht durch (${(ohneZeichnung * 100).toFixed(1)} %)`);
}
/* DRITTE GEGENPROBE: anders als das Workspace-Symbol. */
const gegen = join(VERGLEICH, `workspace-${g.endung}.png`);
let anders = -2;
if (!existsSync(gegen)) {
maengel.push(`${g.endung}: Vergleichsbild workspace-${g.endung}.png fehlt `
+ `-- nicht nachsehbar, ob sich die Symbole unterscheiden`);
} else {
anders = await unterschied(bild, readFileSync(gegen));
verglichen++;
if (anders < 0) {
maengel.push(`${g.endung}: Vergleichsbild hat eine andere Groesse -- nicht vergleichbar`);
} else if (anders < 0.10) {
maengel.push(`${g.endung}: kaum vom Workspace-Symbol zu unterscheiden `
+ `(${(anders * 100).toFixed(1)} %)`);
}
}
console.log(` crew-${g.endung}.png`.padEnd(30)
+ `${(bild.length / 1024).toFixed(1)} KB · Streuung ${streuung.toFixed(1)}`
+ ` · Gesicht ${(ohneZeichnung * 100).toFixed(1)} %`
+ ` · anders als Workspace ${anders < 0 ? "?" : (anders * 100).toFixed(1) + " %"}`);
}
await browser.close();
/* Die Zahlen gehoeren in die Bedingung, nicht nur in den Bericht. */
if (gebaut !== GROESSEN.length) {
console.error(`\nFEHLER: ${gebaut} von ${GROESSEN.length} erzeugt.`); process.exit(1);
}
if (verglichen !== GROESSEN.length) {
console.error(`\nFEHLER: nur ${verglichen} von ${GROESSEN.length} gegen das `
+ `Workspace-Symbol gehalten.`); process.exit(1);
}
if (maengel.length) {
console.error("\nFEHLER:\n " + maengel.join("\n ")); process.exit(1);
}
console.log(`\n${gebaut} Symbole erzeugt. Der Husky ist derselbe wie bei DogFather,`
+ ` das Gesicht ist in jeder grossen Fassung nachweisbar sichtbar, und alle`
+ ` ${verglichen} unterscheiden sich messbar vom Workspace-Symbol.`);
+20 -7
View File
@@ -20,7 +20,7 @@
Gehoert vor jeden Deploy, der JS oder CSS im Workspace anfasst. Gehoert vor jeden Deploy, der JS oder CSS im Workspace anfasst.
===================================================================== */ ===================================================================== */
import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -88,11 +88,24 @@ for (const name of readdirSync(ORDNER)) {
Derselbe Stempel wie in den Seiten -- `pruef-zwischenspeicher` Derselbe Stempel wie in den Seiten -- `pruef-zwischenspeicher`
verlangt im ganzen Haus genau eine Zahl und wuerde eine zweite sofort verlangt im ganzen Haus genau eine Zahl und wuerde eine zweite sofort
melden. */ melden. */
const MANIFEST = join(ORDNER, "app.webmanifest"); /* ES SIND ZWEI MANIFESTE (10.09.2026). Seit die Modi-App unter ihrer
eigenen Adresse laeuft, gibt es neben app.webmanifest auch
crew.webmanifest -- dasselbe Haus, anderer Name, andere Symbole.
ALS LISTE UND NICHT ALS ZWEITER BLOCK: Ein kopierter Block waere beim
dritten Manifest ein dritter, und irgendwann bleibt einer stehen. Ein
fehlendes Manifest ist hier ausserdem ein FEHLER und kein
Ueberspringen -- sonst faellt das Ausbleiben nicht auf. */
const MANIFESTE = ["app.webmanifest", "crew.webmanifest"];
const MUSTER_MANIFEST = const MUSTER_MANIFEST =
/("src":\s*")(\/assets\/img\/app-symbole\/[A-Za-z0-9._-]+)(?:\?v=\d+)?(")/g; /("src":\s*")(\/assets\/img\/app-symbole\/[A-Za-z0-9._-]+)(?:\?v=\d+)?(")/g;
{ for (const name of MANIFESTE) {
const alt = readFileSync(MANIFEST, "utf8"); const pfad = join(ORDNER, name);
if (!existsSync(pfad)) {
console.error(`FEHLER: ${name} fehlt -- erwartet in ${ORDNER}.`);
process.exit(1);
}
const alt = readFileSync(pfad, "utf8");
let n = 0; let n = 0;
const neu = alt.replace(MUSTER_MANIFEST, (_, vor, datei, nach) => { const neu = alt.replace(MUSTER_MANIFEST, (_, vor, datei, nach) => {
n += 1; n += 1;
@@ -102,11 +115,11 @@ const MUSTER_MANIFEST =
Dann hat jemand das Manifest umgebaut, und ab da laeuft das Symbol Dann hat jemand das Manifest umgebaut, und ab da laeuft das Symbol
still auf dem alten Stand weiter. */ still auf dem alten Stand weiter. */
if (n === 0) { if (n === 0) {
console.error("FEHLER: im Manifest keine Symboladresse gefunden -- Muster pruefen."); console.error(`FEHLER: in ${name} keine Symboladresse gefunden -- Muster pruefen.`);
process.exit(1); process.exit(1);
} }
if (neu !== alt) { writeFileSync(MANIFEST, neu); dateien += 1; stellen += n; } if (neu !== alt) { writeFileSync(pfad, neu); dateien += 1; stellen += n; }
console.log(` app.webmanifest ${n} Verweise`); console.log(` ${name.padEnd(20)} ${n} Verweise`);
} }
console.log(`\nStempel ${STEMPEL} auf ${stellen} Verweise in ${dateien} Dateien gesetzt.`); console.log(`\nStempel ${STEMPEL} auf ${stellen} Verweise in ${dateien} Dateien gesetzt.`);
+4 -4
View File
@@ -13,25 +13,25 @@
"theme_color": "#06090f", "theme_color": "#06090f",
"icons": [ "icons": [
{ {
"src": "/assets/img/app-symbole/workspace-192.png?v=202609100303", "src": "/assets/img/app-symbole/workspace-192.png?v=202609101301",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512.png?v=202609100303", "src": "/assets/img/app-symbole/workspace-512.png?v=202609101301",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609100303", "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609101301",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609100303", "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609101301",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+27
View File
@@ -414,6 +414,10 @@
}; };
const event = istEventArt(daten.art); const event = istEventArt(daten.art);
if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value; if (einstellung.dringlichkeit && !event) daten.dringlichkeit = $('f-dringlich').value;
/* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
Speichern mit -- und niemand sieht, woher er kommt. */
if (einstellung.einsatzFeld && $('f-einsatz')) daten.einsatz = $('f-einsatz').value.trim();
if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null; if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null;
if (event) { if (event) {
daten.event_ende = $('f-ende').value || null; daten.event_ende = $('f-ende').value || null;
@@ -506,6 +510,29 @@
$('f-art').append(o); $('f-art').append(o);
} }
$('feld-dringlich').hidden = !einstellung.dringlichkeit; $('feld-dringlich').hidden = !einstellung.dringlichkeit;
/* =================================================================
ZWEI ZAHLEN, ANDERE NAMEN (10.09.2026)
Bei den Angeboten heisst `bewertung` NUTZEN und `dringlichkeit`
AUFWAND. Die Felder gibt es laengst -- sie mit neuen Namen zu
belegen ist besser, als zwei weitere Spalten anzulegen, die
dasselbe koennen. Aber ein Feld, ueber dem "Dringlichkeit" steht
und das den Aufwand meint, ist falsch beschriftet, und das faellt
beim Ausfuellen niemandem auf -- erst beim Lesen.
Die Namen kommen aus den Bereichsangaben; steht dort keiner,
bleibt es wie bisher. */
if (einstellung.dringlichkeitName && $('dringlich-schild')) {
$('dringlich-schild').textContent = einstellung.dringlichkeitName;
}
if (einstellung.bewertungName && $('bewertung-schild')) {
$('bewertung-schild').textContent = `${einstellung.bewertungName} (1–5)`;
}
/* Das Einsatz-Feld nur, wo der Bereich es kennt. */
if ($('feld-einsatz')) $('feld-einsatz').hidden = !einstellung.einsatzFeld;
if (einstellung.einsatzName && $('einsatz-schild')) {
$('einsatz-schild').textContent = einstellung.einsatzName;
}
$('feld-bewertung').hidden = !einstellung.bewertung; $('feld-bewertung').hidden = !einstellung.bewertung;
if (LEITUNG.has(ich.rolle)) { if (LEITUNG.has(ich.rolle)) {
+164 -1
View File
@@ -114,6 +114,165 @@
return k; return k;
} }
/* =====================================================================
DER EINGANG -- was die Modis gemeldet haben.
Er steht OBEN, vor den Karten: Das ist das, was eine Antwort
braucht. Die Zahlen darunter kann man sich ansehen; hier wartet
jemand.
ZWEI ANTWORTEN, und beide kommen beim Modi an. Eine Meldung, auf
die nichts folgt, sieht fuer ihn aus wie eine, die niemand gelesen
hat -- und beim dritten Mal meldet er nichts mehr.
===================================================================== */
function eingangBauen(liste) {
const k = el('section', 'tperson');
k.append(el('p', 'feldschild', 'Rückmeldungen aus dem Team'));
if (!liste.length) {
k.append(el('p', 'tperson__zuletzt',
'Nichts offen – auf alles, was gemeldet wurde, habt ihr geantwortet.'));
return k;
}
for (const m of liste) {
const zeile = el('div', 'tpipe');
zeile.append(el('p', 'tperson__namen', m.titel));
if (m.begruendung) zeile.append(el('p', 'tperson__unter', m.begruendung));
zeile.append(el('span', 'tperson__zuletzt',
`${m.von || 'jemand'} · ${(m.am || '').slice(0, 10)}`));
const knoepfe = el('div', 'tpipe__reihe');
const melden = (t) => { melde(t); };
const ja = el('button', 'schritt', 'Daraus wird eine Aufgabe');
ja.type = 'button';
ja.addEventListener('click', async () => {
ja.disabled = true;
try {
const a = await hole('/workspace/api/teamlage/behandeln', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bereich: m.bereich, schluessel: m.schluessel,
person_id: m.person_id, art: 'aufgabe',
}),
});
if (!a.ok) {
melden((await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.');
ja.disabled = false; return;
}
melden('');
await laden();
} catch { ja.disabled = false; }
});
knoepfe.append(ja);
const nein = el('button', 'abmelden', 'Nicht nötig');
nein.type = 'button';
nein.addEventListener('click', async () => {
/* EINE ABSAGE OHNE GRUND IST KEINE ANTWORT. Deshalb wird
gefragt -- und ein leeres Feld bricht ab, statt still nichts
zu tun. */
const grund = window.prompt(
`Warum machen wir das nicht?
„${m.titel}“`, '');
if (grund === null) return;
if (!grund.trim()) { melden('Ohne Grund bleibt die Meldung stehen.'); return; }
nein.disabled = true;
try {
const a = await hole('/workspace/api/teamlage/behandeln', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bereich: m.bereich, schluessel: m.schluessel,
person_id: m.person_id, art: 'abgelehnt', grund: grund.trim(),
}),
});
if (!a.ok) {
melden((await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.');
nein.disabled = false; return;
}
melden('');
await laden();
} catch { nein.disabled = false; }
});
knoepfe.append(nein);
zeile.append(knoepfe);
k.append(zeile);
}
return k;
}
/* =====================================================================
DIE ANGEBOTE -- Vorschlaege, ueber die ihr entscheidet.
Sie stehen im selben Eingang wie die Rueckmeldungen: EIN Ort, an
dem etwas auf eine Antwort wartet. Wer zwei Eingaenge hat, sieht
irgendwann in den falschen.
===================================================================== */
const AUFWAND = { hoch: 'viel Aufwand', mittel: 'mittlerer Aufwand', niedrig: 'wenig Aufwand' };
async function entscheiden(knopf, koerper) {
knopf.disabled = true;
try {
const a = await hole('/workspace/api/teamlage/angebot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(koerper),
});
if (!a.ok) {
melde((await a.json().catch(() => ({}))).fehler || 'Das hat nicht geklappt.');
knopf.disabled = false; return;
}
const d = await a.json().catch(() => ({}));
melde(d.aufgaben ? `${d.aufgaben} Aufgabe(n) angelegt.` : '');
await laden();
} catch { knopf.disabled = false; }
}
function angeboteBauen(liste) {
const k = el('section', 'tperson');
k.append(el('p', 'feldschild', 'Angebote aus dem Team'));
if (!liste.length) {
k.append(el('p', 'tperson__zuletzt', 'Gerade liegt kein Vorschlag zur Entscheidung.'));
return k;
}
for (const a of liste) {
const zeile = el('div', 'tpipe');
zeile.append(el('p', 'tperson__namen', a.titel));
if (a.text) zeile.append(el('p', 'tperson__unter', a.text));
/* WAS ES FUER DICH HEISST, steht direkt dabei -- sonst muss man
die Entscheidung treffen, ohne den eigenen Teil zu kennen. */
if (a.einsatz) {
zeile.append(el('p', 'tperson__unter', `Dein Teil: ${a.einsatz}`));
}
zeile.append(el('span', 'tperson__zuletzt',
`${a.von_name || 'jemand'} · Nutzen ${a.bewertung ?? '–'}/5 · `
+ `${AUFWAND[a.dringlichkeit] || a.dringlichkeit}`));
const knoepfe = el('div', 'tpipe__reihe');
const ja = el('button', 'schritt', 'Annehmen');
ja.type = 'button';
ja.addEventListener('click', () => entscheiden(ja, { id: a.id, art: 'angenommen' }));
knoepfe.append(ja);
const nein = el('button', 'abmelden', 'Nicht umsetzen');
nein.type = 'button';
nein.addEventListener('click', () => {
const grund = window.prompt(`Warum nicht?
„${a.titel}“`, '');
if (grund === null) return;
if (!grund.trim()) { melde('Ohne Grund bleibt das Angebot stehen.'); return; }
entscheiden(nein, { id: a.id, art: 'abgelehnt', grund: grund.trim() });
});
knoepfe.append(nein);
zeile.append(knoepfe);
k.append(zeile);
}
return k;
}
async function laden() { async function laden() {
const ziel = $('lage'); const ziel = $('lage');
try { try {
@@ -128,11 +287,15 @@
s + Object.values(m.bereiche).reduce((x, b) => x + b.offen, 0), 0); s + Object.values(m.bereiche).reduce((x, b) => x + b.offen, 0), 0);
/* Eine leere Seite ohne Erklaerung sieht aus wie ein Fehler -- /* Eine leere Seite ohne Erklaerung sieht aus wie ein Fehler --
deshalb steht hier auch, was zu tun waere. */ deshalb steht hier auch, was zu tun waere. */
const wartend = (daten.eingang || []).length + (daten.angebote || []).length;
$('kopf-unter').textContent = leute.length $('kopf-unter').textContent = leute.length
? `${leute.length} ${leute.length === 1 ? 'Person' : 'Personen'} · ` ? `${leute.length} ${leute.length === 1 ? 'Person' : 'Personen'} · `
+ `${offenGesamt} Punkte noch nicht besprochen` + `${wartend} ${wartend === 1 ? 'Rückmeldung wartet' : 'Rückmeldungen warten'} · `
+ `${offenGesamt} Punkte noch nicht beurteilt`
: 'Noch niemand im Team. Zugänge legst du unter „Personen & Zugänge“ an.'; : 'Noch niemand im Team. Zugänge legst du unter „Personen & Zugänge“ an.';
ziel.append(eingangBauen(daten.eingang || []));
ziel.append(angeboteBauen(daten.angebote || []));
for (const m of leute) ziel.append(karte(m)); for (const m of leute) ziel.append(karte(m));
} catch { /* umgeleitet */ } } catch { /* umgeleitet */ }
} }
+11 -11
View File
@@ -12,12 +12,12 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -245,10 +245,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/aufgaben.js?v=202609100303" defer></script> <script src="assets/js/aufgaben.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/automation.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/automation.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -140,10 +140,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/automation.js?v=202609100303" defer></script> <script src="assets/js/automation.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+26 -18
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/bereich.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -75,8 +75,16 @@
<label class="feld-schild" for="f-ende">Bis</label> <label class="feld-schild" for="f-ende">Bis</label>
<input id="f-ende" type="date" /> <input id="f-ende" type="date" />
</div> </div>
<!-- Was DogFather dafuer tun muesste. Nur in Bereichen, die das
Feld kennen (siehe BEREICHE.angebote) -- sonst bleibt es
verborgen und wird auch nicht mitgeschickt. -->
<div id="feld-einsatz" hidden>
<label class="feld-schild" for="f-einsatz" id="einsatz-schild">Einsatz</label>
<input id="f-einsatz" type="text" maxlength="300"
placeholder="z. B. Termin ansagen und den Gast einladen" />
</div>
<div id="feld-dringlich" hidden> <div id="feld-dringlich" hidden>
<label class="feld-schild" for="f-dringlich">Dringlichkeit</label> <label class="feld-schild" for="f-dringlich" id="dringlich-schild">Dringlichkeit</label>
<select id="f-dringlich"> <select id="f-dringlich">
<option value="hoch">hoch</option> <option value="hoch">hoch</option>
<option value="mittel" selected>mittel</option> <option value="mittel" selected>mittel</option>
@@ -84,7 +92,7 @@
</select> </select>
</div> </div>
<div id="feld-bewertung" hidden> <div id="feld-bewertung" hidden>
<label class="feld-schild" for="f-bewertung">Bewertung (1–5)</label> <label class="feld-schild" for="f-bewertung" id="bewertung-schild">Bewertung (1–5)</label>
<select id="f-bewertung"> <select id="f-bewertung">
<option value="">—</option> <option value="">—</option>
<option value="1">1 – schwach</option> <option value="1">1 – schwach</option>
@@ -149,14 +157,14 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/checkliste.js?v=202609100303" defer></script> <script src="assets/js/checkliste.js?v=202609101301" defer></script>
<script src="assets/js/ampel.js?v=202609100303" defer></script> <script src="assets/js/ampel.js?v=202609101301" defer></script>
<script src="assets/js/schulung.js?v=202609100303" defer></script> <script src="assets/js/schulung.js?v=202609101301" defer></script>
<script src="assets/js/vorlagen.js?v=202609100303" defer></script> <script src="assets/js/vorlagen.js?v=202609101301" defer></script>
<script src="assets/js/bereich.js?v=202609100303" defer></script> <script src="assets/js/bereich.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/calls.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/calls.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -82,11 +82,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609100303" defer></script> <script src="assets/js/ki.js?v=202609101301" defer></script>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/calls.js?v=202609100303" defer></script> <script src="assets/js/calls.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog, <!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem .neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html. zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
Dialog zu sehen bekam. --> Dialog zu sehen bekam. -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/chat.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/chat.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -247,10 +247,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/bereiche.js?v=202609100303"></script> <script src="assets/js/bereiche.js?v=202609101301"></script>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303"></script> <script src="assets/js/kopf.js?v=202609101301"></script>
<script src="assets/js/glocke.js?v=202609100303"></script> <script src="assets/js/glocke.js?v=202609101301"></script>
<script src="assets/js/chat.js?v=202609100303"></script> <script src="assets/js/chat.js?v=202609101301"></script>
</body> </body>
</html> </html>
+14 -14
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/content.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/content.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -172,12 +172,12 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/checkliste.js?v=202609100303" defer></script> <script src="assets/js/checkliste.js?v=202609101301" defer></script>
<script src="assets/js/vorlagen.js?v=202609100303" defer></script> <script src="assets/js/vorlagen.js?v=202609101301" defer></script>
<script src="assets/js/content.js?v=202609100303" defer></script> <script src="assets/js/content.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+40
View File
@@ -0,0 +1,40 @@
{
"name": "Team Dogi",
"short_name": "Team Dogi",
"description": "Aufgaben, Termine, Ideen und Angebote an einem Ort. Nur mit persönlichem Zugangscode.",
"start_url": "/workspace/start.html",
"id": "/workspace/start.html",
"scope": "/workspace/",
"display": "standalone",
"orientation": "any",
"lang": "de",
"dir": "ltr",
"background_color": "#0a121e",
"theme_color": "#06090f",
"icons": [
{
"src": "/assets/img/app-symbole/crew-192.png?v=202609101301",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-512.png?v=202609101301",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609101301",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609101301",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/dateien.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -101,10 +101,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/dateien.js?v=202609100303" defer></script> <script src="assets/js/dateien.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -30,9 +30,9 @@
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp" <link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" /> type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
</head> </head>
<body class="gate"> <body class="gate">
@@ -401,6 +401,6 @@
</main> </main>
</div> </div>
<script src="assets/js/gate.js?v=202609100303" defer></script> <script src="assets/js/gate.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/kalender.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -370,11 +370,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/kalender.js?v=202609100303" defer></script> <script src="assets/js/kalender.js?v=202609101301" defer></script>
<script src="assets/js/kalender-abo.js?v=202609100303" defer></script> <script src="assets/js/kalender-abo.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/leistung.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -217,10 +217,10 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/wahl.js?v=202609100303"></script> <script src="assets/js/wahl.js?v=202609101301"></script>
<script src="assets/js/bereiche.js?v=202609100303"></script> <script src="assets/js/bereiche.js?v=202609101301"></script>
<script src="assets/js/kopf.js?v=202609100303"></script> <script src="assets/js/kopf.js?v=202609101301"></script>
<script src="assets/js/glocke.js?v=202609100303"></script> <script src="assets/js/glocke.js?v=202609101301"></script>
<script src="assets/js/leistung.js?v=202609100303"></script> <script src="assets/js/leistung.js?v=202609101301"></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/personen.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/personen.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -335,10 +335,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/personen.js?v=202609100303" defer></script> <script src="assets/js/personen.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -207,11 +207,11 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/steckbrief.js?v=202609100303" defer></script> <script src="assets/js/steckbrief.js?v=202609101301" defer></script>
<script src="assets/js/profil.js?v=202609100303" defer></script> <script src="assets/js/profil.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/report.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/report.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -99,11 +99,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609100303" defer></script> <script src="assets/js/ki.js?v=202609101301" defer></script>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/report.js?v=202609100303" defer></script> <script src="assets/js/report.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/scouting.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/scouting.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -132,10 +132,10 @@
</div> </div>
</div> </div>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/scouting.js?v=202609100303" defer></script> <script src="assets/js/scouting.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -12,14 +12,14 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe <!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. --> den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
<link rel="stylesheet" href="assets/css/heim.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/heim.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -667,10 +667,10 @@
</p> </p>
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/start.js?v=202609100303" defer></script> <script src="assets/js/start.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+13 -13
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/startcheck.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/startcheck.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -78,11 +78,11 @@
</main> </main>
<script src="assets/js/ki.js?v=202609100303" defer></script> <script src="assets/js/ki.js?v=202609101301" defer></script>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/startcheck.js?v=202609100303" defer></script> <script src="assets/js/startcheck.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/profil.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/profil.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -119,10 +119,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/steckbrief.js?v=202609100303" defer></script> <script src="assets/js/steckbrief.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -7,13 +7,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/team.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/team.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -103,10 +103,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/team.js?v=202609100303" defer></script> <script src="assets/js/team.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+10 -10
View File
@@ -7,13 +7,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/team.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/team.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -66,8 +66,8 @@
</main> </main>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/teamlage.js?v=202609100303" defer></script> <script src="assets/js/teamlage.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,15 +12,15 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der <!-- Formular- und Dialogstile: seit dem 07.09.2026 steht hier der
Dialog "Creator anlegen". --> Dialog "Creator anlegen". -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/uebersicht.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -142,11 +142,11 @@
</main> </main>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/uebersicht.js?v=202609100303" defer></script> <script src="assets/js/uebersicht.js?v=202609101301" defer></script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -12,13 +12,13 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609100303" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202609101301" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609100303" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202609101301" />
<link rel="stylesheet" href="assets/css/gate.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/gate.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/start.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/start.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/wissen.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/wissen.css?v=202609101301" />
<link rel="stylesheet" href="assets/css/module.css?v=202609100303" /> <link rel="stylesheet" href="assets/css/module.css?v=202609101301" />
</head> </head>
<body class="start"> <body class="start">
@@ -183,10 +183,10 @@
</main> </main>
<script src="assets/js/wahl.js?v=202609100303" defer></script> <script src="assets/js/wahl.js?v=202609101301" defer></script>
<script src="assets/js/bereiche.js?v=202609100303" defer></script> <script src="assets/js/bereiche.js?v=202609101301" defer></script>
<script src="assets/js/kopf.js?v=202609100303" defer></script> <script src="assets/js/kopf.js?v=202609101301" defer></script>
<script src="assets/js/glocke.js?v=202609100303" defer></script> <script src="assets/js/glocke.js?v=202609101301" defer></script>
<script src="assets/js/wissen.js?v=202609100303" defer></script> <script src="assets/js/wissen.js?v=202609101301" defer></script>
</body> </body>
</html> </html>