Rueckmeldung in beide Richtungen (Kapitel 5 und 6)

Filipe: "Nicht nur ich soll meine Modis bewerten oder ihnen Feedback
geben koennen. Auch die Modis sollen mir Feedback geben koennen. Sie
sollen mir beispielsweise sagen koennen: Was koennte ich verbessern?"
Und der Schlusssatz seines Pflichtenhefts: "Es soll nicht nur dazu
dienen, Leistungen zu kontrollieren. Es soll vor allem dabei helfen,
als Team besser zu werden."

EIN BRETT FUER BEIDE KAPITEL, NICHT ZWEI. Kapitel 5 (gegenseitiges
Feedback) und Kapitel 6 (gemeinsame Reflexion) stellen dieselben
Fragen -- "was laeuft gut, was laeuft schlecht, was fehlt" --, einmal
an eine Person und einmal an das Team. Zwei Bretter haetten bedeutet,
dass man beim Schreiben zuerst entscheiden muss, an WEN es geht, bevor
man weiss, WAS man sagen will. Hier ist es umgekehrt: erst die Sache,
dann die Richtung.

DIE NEUN FRAGEN SIND SEINE, wortwoertlich aus dem Pflichtenheft
zusammengezogen: laeuft gut · laeuft nicht gut · unbedingt behalten ·
an DogFather · was dem Team fehlt · Regel aendern · besser organisieren
· Idee · Wunsch fuer spaeter. Sie stehen als feste Faecher da und nicht
als freies Feld -- genau das ist der Unterschied zwischen einer
Sammlung und einem Haufen: Neun Faecher kann man auswerten, tausend
Formulierungen nicht.

KEIN NEUER BAUKASTEN. Die Rueckmeldung ist ein BEREICH wie das
Ideen-Board: dieselbe Tabelle, dieselbe Seite, dieselben Regeln fuers
Anlegen, Aendern und Loeschen, dieselbe Zugangssperre. Ein eigenes
Modul haette all das ein zweites Mal gebraucht -- und die zweite
Fassung waere die gewesen, in der eine Regel fehlt.

DIE EINE NEUE SACHE IST DIE RICHTUNG. Beim Schreiben waehlt man
zwischen "fuers Team" (Vorgabe) und "nur an DogFather". Ohne diese Wahl
haette man eines von beidem verloren: Wer "was koenntest du besser
machen" vor versammelter Mannschaft sagen muss, sagt es nicht -- wer
alles nur unter vier Augen sagen kann, hat kein Team-Gespraech.

UND "NUR DOGFATHER" HEISST NUR DOGFATHER -- die rechte Hand
ausdruecklich nicht. Sie sieht sonst ueberall dasselbe wie er; hier
nicht, weil das Etikett sonst nicht stimmen wuerde. Eine Zusage mit
einer Ausnahme im Kleingedruckten ist keine. Sie schreibt selbst
genauso -- auch ueber ihn.

DIE ZUSAGE STEHT IN sichtbarEintrag(), also in derselben Funktion, durch
die auch das Lesen einer einzelnen Zeile, das Aendern und das Loeschen
gehen. Eine Regel, die nur die Liste filtert, laesst die Zeile ueber
ihre Nummer trotzdem heraus; die Pruefung klopft deshalb auch von
hinten (PATCH und DELETE auf die vertrauliche Zeile: 404, auf die
offene: 200).

`COALESCE(nur_leitung, 0)`: Jede Zeile, die es vor heute gab, hat dort
NULL, und in SQL ist `NULL = 0` nicht falsch, sondern UNBEKANNT. Ohne
den Ersatzwert waere der gesamte alte Bestand von einer Minute auf die
andere unsichtbar gewesen -- und niemand haette es gemeldet, denn ein
leeres Brett sieht nicht nach Fehler aus. Beide Richtungen sind
gemessen.

KEINE ANONYMITAET, und das ist eine Entscheidung, keine Luecke. In
einem Team dieser Groesse waere sie ohnehin keine: An drei Saetzen
erkennt jeder jeden. Ein Versprechen, das nicht haelt, ist schlimmer
als keines.

DER FARBTON DER KACHEL IST AUSGERECHNET, NICHT AUSGESUCHT. Bei 24
vorhandenen Toenen landet ein neuer fast zwangslaeufig neben einem
alten, und zwei Kacheln in FAST derselben Farbe sind schlimmer als in
derselben -- bei gleicher merkt man den Fehler, bei fast gleicher sucht
man ihn. Alle 24 wurden in Farbwinkel umgerechnet; die groesste Luecke
liegt zwischen 90 und 160 Grad und ist 70 Grad breit, mehr als doppelt
so viel wie die naechste. Der neue Ton sitzt in ihrer Mitte, 8,9 zu 1
auf dunklem Grund.

pruef-rueckmeldung 34 (neu) · pruef-rollen 274 -> 277 ·
pruef-start-ansicht zaehlt jetzt 25 Kacheln und 25 Farben (sie zaehlt
selbst, statt eine Zahl festzuhalten -- deshalb blieb sie gruen) ·
pruef-modi-ideen 30 · pruef-modi-verborgen 78 · pruef-css-klassen gruen
· pruef-modi-wortleck 5.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 22:54:04 +02:00
co-authored by Claude Opus 5
parent 72b36d1c31
commit be121a483e
30 changed files with 850 additions and 293 deletions
+309
View File
@@ -0,0 +1,309 @@
/* RÜCKMELDUNG IN BEIDE RICHTUNGEN (10.09.2026)
Kapitel 5 und 6 des Pflichtenhefts:
"Nicht nur ich soll meine Modis bewerten oder ihnen Feedback geben
können. Auch die Modis sollen mir Feedback geben können. Sie sollen
mir beispielsweise sagen können: Was könnte ich verbessern?"
"Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es soll
vor allem dabei helfen, als Team besser zu werden."
---------------------------------------------------------------------
DIE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT
Wer beim Schreiben „nur an DogFather" wählt, verlässt sich darauf.
Diese Zusage hat genau eine Stelle, an der sie gilt --
`sichtbarEintrag()` --, und genau zwei Arten, still zu brechen:
Sie könnte NICHT WEIT GENUG gehen: Die Liste ist gefiltert, aber
über die Nummer der Zeile (Ändern, Löschen) kommt man trotzdem
heran. Deshalb wird hier auch von hinten geklopft.
Sie könnte ZU WEIT gehen: `nur_leitung` ist eine neue Spalte, und
jede Zeile, die es vorher gab, hat dort NULL. In SQL ist
`NULL = 0` nicht falsch, sondern UNBEKANNT -- ohne COALESCE wäre
der gesamte alte Bestand von einer Minute auf die andere
unsichtbar gewesen. Ein leeres Brett sieht nicht nach Fehler aus;
das hätte niemand gemeldet.
Beide Fälle stehen unten, jeder mit seiner Gegenprobe.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-rueck-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4371, "die Rueckmeldungspruefung");
process.env.PORT = "4371";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4371";
/* Netz und Notbremse -- index.js faengt sonst jeden Absturz ab, und der
Lauf sieht von aussen aus wie "laeuft noch". */
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => { console.error(""); console.error("ABBRUCH:", f?.stack || f); process.exit(1); });
}
setTimeout(() => { console.error(""); console.error("ABBRUCH: 120 s vorbei."); process.exit(1); },
120000).unref?.();
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Vanessa", "hand", "CODE-HAND-0001");
const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
anlegen("Ben", "modi", "CODE-TEAM-0002");
anlegen("Cigdem", "manager", "CODE-MANA-0001");
/* Team-Rollen kommen nur ueber die Crew-Wand herein, Agenturrollen nur
ueber die Agenturwand -- die Regel vom 10.09.2026. `fetch` kann den
Host-Kopf nicht setzen (undici setzt ihn aus der Adresse), deshalb
node:http. */
const { request: httpAnfrage } = await import("node:http");
function anmelden(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: 4371, path: "/workspace/api/anmelden", method: "POST",
headers: { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf), Host: wand },
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
}));
});
a.on("error", () => fertig({ ok: false, keks: "" }));
a.end(rumpf);
});
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const hand = await anmelden("hand", "CODE-HAND-0001");
const ayla = await anmelden("modi", "CODE-TEAM-0001");
const ben = await anmelden("modi", "CODE-TEAM-0002");
const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com");
ok([dogi, hand, ayla, ben, cigdem].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet");
const WEG = "/workspace/api/bereich/rueckmeldung";
const titel = async (keks) =>
((await holen(keks, WEG)).eintraege || []).map((e) => e.titel);
/* =======================================================================
1. Es gibt den Bereich, und er hat die Fragen aus dem Pflichtenheft
======================================================================= */
console.log("");
console.log("=== Der Bereich ===");
{
const a = await holen(dogi.keks, WEG);
ok(a.einstellung?.name === "Rückmeldung", `DogFather kann ihn oeffnen (${a.einstellung?.name})`);
const arten = Object.keys(a.einstellung?.arten || {});
ok(arten.length === 9, `neun Fragen (${arten.join(", ")})`);
ok(a.einstellung?.vertraulich === true, "und er kennt die vertrauliche Zeile");
const b = await holen(ayla.keks, WEG);
ok(b.einstellung?.name === "Rückmeldung", "das Team kann ihn auch oeffnen");
/* GEGENPROBE: Die Agentur hat hier nichts zu suchen -- 404 und nicht
403, sonst waere allein die Antwort schon eine Auskunft. */
const c = await rufe(cigdem.keks, WEG);
ok(c.status === 404, `ein Manager kommt nicht hinein (${c.status})`);
}
/* =======================================================================
2. Beide Richtungen
======================================================================= */
console.log("");
console.log("=== Wer schreibt wem ===");
let offenId = 0, geheimId = 0;
{
const a = await rufe(ayla.keks, WEG, {
art: "gut", titel: "Die Absprachen vor dem Live helfen sehr",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
offenId = (await a.json())?.id;
ok(a.status === 201 && offenId > 0, `ein Teammitglied schreibt fuers Team (${a.status})`);
const b = await rufe(ayla.keks, WEG, {
art: "dogfather", titel: "Du unterbrichst uns oft mitten im Satz",
datum: "2026-09-10", dringlichkeit: "mittel", nur_leitung: 1,
}, "POST");
geheimId = (await b.json())?.id;
ok(b.status === 201 && geheimId > 0, `und eine nur an DogFather (${b.status})`);
const c = await rufe(dogi.keks, WEG, {
art: "behalten", titel: "Die Schichtübergabe so lassen",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
ok(c.status === 201, `DogFather schreibt in dieselbe Sammlung (${c.status})`);
/* Die Spalte muss wirklich gesetzt sein -- nicht nur die Antwort
stimmen. Ohne diese Zeile waere die Pruefung gruen, wenn das Feld
stillschweigend verworfen wuerde. */
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(geheimId);
ok(zeile?.nur_leitung === 1, `die Zeile steht als vertraulich in der Datenbank (${zeile?.nur_leitung})`);
const offen = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(offenId);
ok(offen?.nur_leitung === 0, `und die andere als offen (${offen?.nur_leitung})`);
}
/* =======================================================================
3. DIE ZUSAGE: „nur DogFather" heisst nur DogFather
======================================================================= */
console.log("");
console.log("=== Wer sieht was ===");
{
const beiDogi = await titel(dogi.keks);
ok(beiDogi.some((t) => t.startsWith("Du unterbrichst")),
`DogFather sieht die vertrauliche Zeile (${beiDogi.length} Eintraege)`);
const beiAyla = await titel(ayla.keks);
ok(beiAyla.some((t) => t.startsWith("Du unterbrichst")),
"die Schreiberin sieht ihre eigene");
/* DIE EIGENTLICHE PRUEFUNG. Die rechte Hand sieht sonst ueberall
dasselbe wie DogFather -- hier nicht. */
const beiHand = await titel(hand.keks);
ok(!beiHand.some((t) => t.startsWith("Du unterbrichst")),
`die rechte Hand sieht sie NICHT (${beiHand.join(" | ")})`);
const beiBen = await titel(ben.keks);
ok(!beiBen.some((t) => t.startsWith("Du unterbrichst")),
"ein anderes Teammitglied auch nicht");
/* GEGENPROBE: Sonst koennte "sieht sie nicht" auch heissen "sieht
ueberhaupt nichts". Die OFFENE Zeile muss bei beiden ankommen. */
ok(beiHand.some((t) => t.startsWith("Die Absprachen")),
"die offene Zeile sehen beide sehr wohl");
ok(beiBen.some((t) => t.startsWith("Die Absprachen")),
"auch das andere Teammitglied");
}
/* =======================================================================
4. Und von hinten? Die Nummer ist kein Schluessel
======================================================================= */
console.log("");
console.log("=== Ueber die Nummer ===");
{
const a = await rufe(hand.keks, `${WEG}/${geheimId}`, { titel: "umgeschrieben" }, "PATCH");
ok(a.status === 404, `die rechte Hand kann sie nicht aendern (${a.status})`);
const b = await rufe(hand.keks, `${WEG}/${geheimId}`, undefined, "DELETE");
ok(b.status === 404, `und nicht loeschen (${b.status})`);
const heil = d.prepare("SELECT titel FROM eintraege WHERE id = ?").get(geheimId);
ok(heil?.titel?.startsWith("Du unterbrichst"), "die Zeile steht unveraendert da");
/* GEGENPROBE: An der OFFENEN Zeile darf sie arbeiten -- sonst hiesse
die 404 oben nur "diese Route geht fuer sie nie". */
const c = await rufe(hand.keks, `${WEG}/${offenId}`, { status: "erledigt" }, "PATCH");
ok(c.status === 200, `an der offenen Zeile darf sie arbeiten (${c.status})`);
}
/* =======================================================================
5. Der alte Bestand (NULL) bleibt sichtbar
======================================================================= */
console.log("");
console.log("=== Was vor der Spalte da war ===");
{
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von, nur_leitung)
VALUES ('rueckmeldung','idee','Aus der Zeit vor der Spalte',?,?,?,NULL)`)
.run("2026-09-01", jetzt, idAyla);
const beiHand = await titel(hand.keks);
ok(beiHand.some((t) => t === "Aus der Zeit vor der Spalte"),
"eine Zeile mit NULL gilt als offen und bleibt sichtbar");
/* Die Gegenprobe zur Gegenprobe: Genau diese Zeile MIT 1 waere weg. */
d.prepare("UPDATE eintraege SET nur_leitung = 1 WHERE titel = 'Aus der Zeit vor der Spalte'").run();
const nachher = await titel(hand.keks);
ok(!nachher.some((t) => t === "Aus der Zeit vor der Spalte"),
"dieselbe Zeile auf 1 gesetzt ist fuer sie weg -- die Regel greift wirklich");
d.prepare("UPDATE eintraege SET nur_leitung = NULL WHERE titel = 'Aus der Zeit vor der Spalte'").run();
}
/* =======================================================================
6. In anderen Bereichen gibt es das Feld nicht
======================================================================= */
console.log("");
console.log("=== Kein Versteck in anderen Bereichen ===");
{
const a = await rufe(ayla.keks, "/workspace/api/bereich/ideen", {
art: "content", titel: "Eine ganz normale Idee", datum: "2026-09-10",
nur_leitung: 1,
}, "POST");
const id = (await a.json())?.id;
ok(a.status === 201, `eine Idee laesst sich anlegen (${a.status})`);
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(id);
ok(!zeile?.nur_leitung,
`das Feld wird dort stillschweigend verworfen (${zeile?.nur_leitung})`);
const beiHand = await holen(hand.keks, "/workspace/api/bereich/ideen");
ok((beiHand.eintraege || []).some((e) => e.id === id),
"und die Idee steht fuer alle da -- niemand kann sich dort verstecken");
}
/* =======================================================================
7. Die Kachel steht bei allen dreien -- mit demselben Namen
======================================================================= */
console.log("");
console.log("=== Die Kachel ===");
{
const kacheln = async (keks) => {
const i = await holen(keks, "/workspace/api/ich");
return [...(i.bereiche || []), ...(i.bereiche_zusatz || [])];
};
for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks],
["das Team", ayla.keks]]) {
const k = (await kacheln(keks)).filter((x) => x.ziel === "bereich.html?b=rueckmeldung");
ok(k.length === 1 && k[0].name === "Rückmeldung",
`${wer}: genau eine Kachel, und sie heisst gleich (${k.map((x) => x.name).join("/") || "keine"})`);
}
/* Der Farbton muss neu sein -- zwei Kacheln in derselben Farbe sind
ein Fehler, den pruef-start-ansicht sonst erst am Bildschirm
findet. */
const alle = await kacheln(dogi.keks);
const toene = alle.map((x) => x.ton).filter((t) => t !== undefined);
ok(new Set(toene).size === toene.length,
`kein Farbton doppelt (${toene.length} Kacheln vom Server)`);
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);