Bewerbungen: die Absage bleibt lesbar, und ihr seht, was ihr absagt

VanVan im Support: „Wenn sich ein Modi auf eine Aufgabe bewirbt und
man diese ablehnt, dann bekommt der Modi keine Benachrichtigung
darüber, dass die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht. Außerdem haben auch wir nirgendwo eine
Übersicht, bei wem wir welche Aufgaben schon abgelehnt haben."

GEMESSEN, BEVOR GEBAUT WURDE -- die Pruefungen standen zuerst da und
waren rot:

    Frida sieht ihre beantwortete Bewerbung     0
    Die Leitung sieht die Absagen               0

DIE URSACHE. `vorlagenBewerbungenFuer` fragt `WHERE zustand =
'beworben'`. Sobald jemand antwortet, faellt die Zeile aus JEDER
Ansicht heraus -- mitsamt der Begruendung, die Filipe am 23.09.
ausdruecklich verlangt hat („mit einem text als notiz"). Der Satz
wird also verlangt, geschrieben und weggesperrt.

Bei einer ZUSAGE fiel das nicht auf: Dort entsteht eine Aufgabe, und
an ihrer Karte steht die Antwort. Bei einer ABSAGE entsteht nichts.

Die Benachrichtigung selbst gab es (`bewerbung_antwort`, vorgabe an,
fuer ja und nein). Sie war nur das EINZIGE -- wer sie wegwischt oder
kein Geraet angemeldet hat, erfuhr nie, warum.

WAS JETZT DASTEHT

* BEIM BEWERBER: „Antwort auf deine Bewerbung" mit dem Ergebnis, dem
  Satz und dem Namen dessen, der geantwortet hat. Beide Ausgaenge --
  eine Liste, die nur Absagen sammelt, waere eine andere Sache. Nach
  14 Tagen verschwindet sie von selbst.

  SIE STEHT VOR DEM ZUKLAPPEN. Beim ersten Anlauf lag sie im Koerper
  des Vorlagenbretts, und der wird nur gebaut, wenn das Brett
  aufgeklappt ist -- zugeklappt ist die Vorgabe. Eine Antwort, die
  man erst aufklappen muss, ist wieder keine.

* BEI DER LEITUNG: „Schon abgesagt", 90 Tage, mit der Zahl JE MENSCH
  vorn. Das ist VanVans eigentliche Frage („falls sich jemand immer
  wieder bewirbt und immer wieder abgelehnt wird"), und die
  beantwortet eine Liste von zwanzig Zeilen nicht.

  AUS BEIDEN WEGEN -- Vorlage und Aufgabe. Eine Uebersicht, die nur
  die Haelfte zeigt, ist schlimmer als keine: „Frida wurde nie
  abgelehnt" waere dann eine Auskunft, die stimmt und taeuscht.

  ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht, ist eine
  Sammlung von Nein-Sagen ueber Menschen, mit denen man morgen wieder
  arbeitet.

  KEIN ROT. Eine Absage ist kein Fehler; „diesmal nicht" ist keine
  Aussage ueber den Menschen. Gedeckter Bernstein, und ab zwei Malen
  faellt die ZAHL auf -- nicht nur die Farbe.

ZWEI EIGENE FELDER UND NICHT `bewerbungen` ERWEITERT. Dort heisst
eine Zeile im Browser „wartet auf Antwort", samt Zurueckziehen-Knopf.
Beantwortete Zeilen hineinzumischen haette bei einer Absage genau das
gezeigt. Ein Feld, dessen Bedeutung sich aendert, bricht seine Leser
lautlos.

`entschieden_von IS NOT NULL` TRENNT DREI DINGE, die alle `abgelehnt`
heissen: die Leitung hat eine Bewerbung abgelehnt (das hier), die
Person hat eine Zuteilung abgelehnt (ihre Entscheidung), und „von
jemand anderem uebernommen" (gar keine Absage).

Haustrennung gilt: `darfSchreibenMit` je Zeile.

GEPRUEFT
pruef-bewerbung-aufgaben 144/0 (weitere 10 Pruefungen plus zwei
Bilder). Die Gegenprobe steht in der Vorgeschichte: Dieselben
Pruefungen waren vor dem Bau rot.

Zwei eigene Messfehler, beide im Text festgehalten: Ich habe zuerst
auf dem AUFGABENbrett gemessen -- der Vorlagenkatalog steht aber auf
„Eure Aufgaben" (`zweig: 'team'`). Und `innerText` liefert den Text
so, wie er DASTEHT: Die Zeile suchte „Diesmal nicht" und fand
„DIESMAL NICHT", weil das Schild `text-transform: uppercase` traegt.

pruef-vorlagen 24/0, pruef-aufgaben-vorlagen, pruef-zuteilung,
pruef-aufgabenbrett, pruef-entwicklung 79/0, pruef-css-klassen,
pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-30 18:34:24 +02:00
co-authored by Claude Opus 5
parent c933bcd2e0
commit 5e26df788b
49 changed files with 1240 additions and 671 deletions
+169
View File
@@ -696,6 +696,175 @@ melde("=== 6. Am echten Bildschirm ===");
ok(!!b && b.knoepfe.some((t) => /fange an/i.test(t)),
`sondern „Ich fange an" (${b?.knoepfe.join(" | ")})`);
} finally { await m4.ctx.close(); }
/* ===============================================================
UND JETZT EINE ABSAGE -- VanVans vierte Meldung (30.09.2026)
„dann bekommt der Modi keine Benachrichtigung darüber, dass
die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht.
Außerdem haben auch wir nirgendwo eine Übersicht, bei wem wir
welche Aufgaben schon abgelehnt haben."
Bei einer ZUSAGE entsteht eine Aufgabe, und an ihr steht die
Antwort. Bei einer ABSAGE entsteht nichts -- die Begruendung
wird in `vorlagen_bewerbungen.entscheid_text` geschrieben und
danach von niemandem mehr ausgeliefert:
`vorlagenBewerbungenFuer` fragt `WHERE zustand = 'beworben'`.
Der Satz wird also verlangt, geschrieben und weggesperrt.
=============================================================== */
const v2 = katalogAufgaben.at(-2);
const vb2 = await roh("/workspace/api/vorlagen/bewerben", {
methode: "POST", keks: modi.keks,
body: { vorlage: v2.schluessel, text: "Die würde ich auch gern machen." } });
ok(vb2.code === 201 || vb2.code === 200,
`Frida bewirbt sich auf „${v2.titel}" (${vb2.code})`);
const offen3 = await roh("/workspace/api/vorlagen/bewerbungen", { keks: hand.keks });
const bw2 = (offen3.json?.bewerbungen || []).find((x) => x.vorlage === v2.schluessel);
const GRUND = "Diese Woche nicht — du hast schon drei offene.";
const vab = await roh("/workspace/api/vorlagen/bewerbung/ablehnen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id, kommentar: GRUND } });
ok(vab.code === 200, `die rechte Hand lehnt ab, mit Begruendung (${vab.code})`);
/* ---- 1. SIEHT FRIDA DIE ANTWORT? ------------------------- */
const beiFrida = await roh("/workspace/api/vorlagen", { keks: modi.keks });
/* `antworten` UND NICHT `bewerbungen`. Das alte Feld heisst im
Browser „wartet auf Antwort" -- beantwortete Zeilen darin
haetten bei einer Absage genau das angezeigt. */
const ihre = beiFrida.json?.antworten || [];
const abgelehnt = ihre.find((x) => x.vorlage === v2.schluessel);
ok(!!abgelehnt,
`Frida sieht ihre beantwortete Bewerbung (${ihre.length} insgesamt)`);
ok(abgelehnt?.zustand === "abgelehnt",
`und zwar als abgelehnt (${abgelehnt?.zustand})`);
ok(abgelehnt?.entscheid_text === GRUND,
`mit der Begruendung, die geschrieben wurde (${JSON.stringify(abgelehnt?.entscheid_text)})`);
/* ---- 2. SIEHT DIE LEITUNG, WAS SIE ABGELEHNT HAT? -------- */
const beiHand = await roh("/workspace/api/vorlagen", { keks: hand.keks });
const absagen = beiHand.json?.absagen;
const verlauf = absagen?.liste || [];
ok(verlauf.length >= 1,
`die Leitung sieht die Absagen der letzten ${absagen?.seit_tagen} Tage `
+ `(${verlauf.length})`);
const eintrag = verlauf.find((x) => x.kennung === v2.schluessel);
ok(!!eintrag && eintrag.person_name === "Frida",
`mit Namen und Aufgabe (${eintrag?.person_name} / ${eintrag?.titel})`);
ok(eintrag?.entscheid_text === GRUND,
`und der eigenen Begruendung (${JSON.stringify(eintrag?.entscheid_text)})`);
/* VANVANS EIGENTLICHE FRAGE: „Falls sich jemand immer wieder auf
Aufgaben bewirbt die immer wieder abgelehnt werden, wäre das
sinnvoll." Eine Liste beantwortet das nicht, eine Zahl je
Mensch schon. */
const frida = (absagen?.je_mensch || []).find((x) => x.name === "Frida");
ok(!!frida && frida.anzahl >= 1,
`und wie oft es je Mensch war (Frida: ${frida?.anzahl})`);
/* BEIDE WEGE STEHEN DRIN. Weiter oben hat DogFather zwei
Bewerbungen auf AUFGABEN abgelehnt. Eine Uebersicht, die nur
die Vorlagen zeigt, waere eine Auskunft, die stimmt und
taeuscht. */
ok(verlauf.some((x) => x.quelle === "aufgabe")
&& verlauf.some((x) => x.quelle === "vorlage"),
`aus beiden Wegen (${[...new Set(verlauf.map((x) => x.quelle))].join(", ")})`);
/* GEGENPROBE: Frida selbst bekommt die Uebersicht NICHT -- sie
entscheidet nicht, und eine Liste der Absagen an andere geht
sie nichts an. */
ok(beiFrida.json?.absagen === null,
`ein Modi bekommt die Uebersicht nicht (${JSON.stringify(beiFrida.json?.absagen)})`);
/* ---- UND AM ECHTEN BILDSCHIRM -----------------------------
„Der Server liefert es" waere ein gruener Haken ueber etwas,
das niemand sieht. Das Vorlagenbrett steht auf der
Aufgabenseite -- also aufmachen und nachlesen. */
const liesBrett = (seite) => seite.evaluate(() => {
const a = document.querySelector(".vorlagenbrett__antwort");
const u = document.querySelector(".vorlagenbrett__absagen");
return {
antwort: a ? (a.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zeilen: a ? a.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
absagen: u ? (u.innerText || "").replace(/\s+/g, " ").trim().slice(0, 220) : null,
zugeklappt: u ? !u.open : null,
leute: u ? [...u.querySelectorAll(".vorlagenbrett__absagen-mensch")]
.map((x) => x.textContent.trim()) : [],
};
});
/* AUF „EURE AUFGABEN" UND NICHT AUF DEM AUFGABENBRETT. Dort
bewirbt man sich, dort steht der Vorlagenkatalog
(`zweig: 'team'` in entwicklung.js). Das Aufgabenbrett zeigt
den anderen Zweig (`creator`, den Aufbauplan) -- beim ersten
Anlauf habe ich dort gemessen und nichts gefunden. Ein
Messfehler, der wie ein Befund aussieht. */
const m5 = await seiteAls("modi", "CODE-MODI-0001", 390);
try {
await m5.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const w = await liesBrett(m5.s);
ok(w.zeilen >= 1, `Frida sieht die Antwort auf ihrem Brett (${w.zeilen} Zeilen)`);
/* OHNE RUECKSICHT AUF GROSS UND KLEIN. `innerText` liefert den
Text SO, WIE ER DASTEHT -- und das Schild traegt
`text-transform: uppercase`. Die Zeile suchte „Diesmal
nicht" und fand „DIESMAL NICHT": rot, obwohl alles stimmte.
`textContent` haette den Rohtext gegeben, aber `innerText`
ist hier richtig: Gemessen wird, was ein Mensch liest. */
ok(/diesmal nicht/i.test(w.antwort || ""),
`und dass es diesmal nichts wurde („${(w.antwort || "").slice(0, 60)}…")`);
ok(!!w.antwort && w.antwort.includes("schon drei offene"),
"mitsamt der Begruendung");
/* GEGENPROBE: Die Uebersicht der Absagen sieht sie NICHT. */
ok(w.absagen === null,
`und die Uebersicht der Absagen nicht (${JSON.stringify(w.absagen)})`);
/* UND EIN BILD DAVON -- am Handy, denn dort liest ein Modi. */
const kasten2 = await m5.s.$(".vorlagenbrett__antwort");
if (kasten2) {
await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" });
console.log(" -- Bild: server/pruef-bewerbung-antwort.png");
}
} finally { await m5.ctx.close(); }
const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280);
try {
await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
await h2.s.waitForTimeout(1800);
/* DAS BRETT AUFKLAPPEN. Die Uebersicht ist ein
Nachschlagewerk und liegt im Koerper des Vorlagenbretts --
der wird zugeklappt gar nicht erst gebaut. Die ANTWORT auf
die eigene Bewerbung steht dagegen davor: Sie ist eine
Nachricht, und eine Nachricht, die man aufklappen muss,
ist keine. */
await h2.s.evaluate(() => {
const k = document.querySelector("#vorlagenbrett .vorlagenbrett__kopf");
if (k && k.getAttribute("aria-expanded") !== "true") k.click();
});
await h2.s.waitForTimeout(1200);
const w = await liesBrett(h2.s);
ok(!!w.absagen, `die rechte Hand sieht die Uebersicht („${(w.absagen || "").slice(0, 50)}…")`);
/* ZUGEKLAPPT. Eine Liste von Absagen, die immer offen steht,
ist eine Sammlung von Nein-Sagen ueber Menschen, mit denen
man morgen wieder arbeitet. */
ok(w.zugeklappt === true, `und zwar zugeklappt (${w.zugeklappt})`);
ok(w.leute.some((t) => /Frida/.test(t)),
`mit der Zahl je Mensch (${w.leute.join(" | ")})`);
/* EIN BILD VON DEM, WAS NEU IST. Zahlen sagen, dass etwas da
ist; ob es auch auszuhalten ist, sieht man nur. Aufgeklappt
-- zugeklappt waere ein Bild von einer Zeile. */
await h2.s.evaluate(() => {
const u = document.querySelector(".vorlagenbrett__absagen");
if (u) u.open = true;
});
await h2.s.waitForTimeout(300);
const kasten = await h2.s.$("#vorlagenbrett");
if (kasten) {
await kasten.screenshot({ path: "server/pruef-bewerbung-absagen.png" });
console.log(" -- Bild: server/pruef-bewerbung-absagen.png");
}
} finally { await h2.ctx.close(); }
}
/* =================================================================
+157
View File
@@ -48,6 +48,11 @@ import {
MODI_STUFEN,
entscheidetUeberAufgaben,
darfAufgabenVerteilen,
/* Fuer die Absagen-Uebersicht (30.09.2026): Wer den Menschen gar
nicht sehen darf, sieht auch nicht, dass ihm abgesagt wurde.
Ohne diese Zeile erfuehre die Agentur ueber eine Uebersicht,
dass es Team Dogi gibt. */
darfSchreibenMit,
KATALOG_EMPFAENGER,
KATALOG_EMPFAENGER_SQL,
} from "./workspace.js";
@@ -1468,6 +1473,18 @@ vorlagenRouter.get("/workspace/api/vorlagen", (req, res) => {
bekommt jeder, der die Seite oeffnet. */
bewerbungen: kategorienFuer(req.person).length
? vorlagenBewerbungenFuer(req.person) : null,
/* ==== UND WAS SCHON BEANTWORTET IST (30.09.2026) =============
ZWEI NEUE FELDER UND NICHT `bewerbungen` ERWEITERT. Dort heisst
eine Zeile im Browser „wartet auf Antwort" -- daraus wird ein
Schild und ein Zurueckziehen-Knopf. Beantwortete Zeilen
hineinzumischen hiesse, bei einer Absage „wartet auf Antwort"
zu zeigen. Ein Feld, dessen Bedeutung sich aendert, bricht
seine Leser lautlos. */
antworten: kategorienFuer(req.person).length
? vorlagenAntwortenFuer(req.person) : null,
absagen: kategorienFuer(req.person).length
? absagenUebersicht(req.person) : null,
darf_entscheiden: entscheidetUeberAufgaben(req.person),
darf_verteilen: darfAufgabenVerteilen(req.person),
});
@@ -1562,6 +1579,146 @@ function katalogAufgabeAnlegen(v, zielId, vonId, zustand = "offen") {
drueben bei den Aufgaben -- ein Satz, kein Aufsatz. */
const BEWERBUNG_TEXT_MAX = 600;
/* =====================================================================
WAS NACH EINER ANTWORT UEBRIG BLEIBT (30.09.2026)
VanVan im Support: „dann bekommt der Modi keine Benachrichtigung
darüber, dass die Aufgabe abgelehnt wurde und sieht somit auch eine
eventuelle Begründung nicht. Außerdem haben auch wir nirgendwo eine
Übersicht, bei wem wir welche Aufgaben schon abgelehnt haben."
Gemessen, bevor gebaut wurde: Frida sah nach der Absage NULL
beantwortete Bewerbungen, die Leitung NULL Absagen.
Der Grund steht eine Funktion weiter unten: `WHERE zustand =
'beworben'`. Sobald jemand antwortet, faellt die Zeile aus jeder
Ansicht heraus -- mitsamt der Begruendung, die man ausdruecklich
verlangt hat.
Bei einer ZUSAGE fiel das nicht auf: Dort entsteht eine Aufgabe,
und an ihrer Karte steht die Antwort. Bei einer ABSAGE entsteht
nichts.
===================================================================== */
/** Wie lange eine beantwortete Bewerbung beim Bewerber stehen bleibt.
* Zwei Wochen: lang genug, dass man sie sieht, kurz genug, dass das
* Brett nicht zur Ablage wird. */
const ANTWORT_TAGE = 14;
/** Wie weit die Absagen-Uebersicht zurueckreicht.
* Drei Monate -- VanVans Frage ist „bewirbt sich jemand immer wieder
* auf etwas, das wir immer wieder ablehnen?", und die beantwortet
* eine Woche nicht. */
const ABSAGEN_TAGE = 90;
const vorTagen = (n) => new Date(Date.now() - n * 86400_000).toISOString();
/** Meine eigenen beantworteten Bewerbungen -- mit der Begruendung.
*
* NUR DIE EIGENEN, ausdruecklich. Wer entscheidet, sieht die Absagen
* in der Uebersicht darunter; hier geht es um die Antwort, auf die
* man selbst gewartet hat.
*/
function vorlagenAntwortenFuer(person) {
try {
return db().prepare(`
SELECT b.id, b.vorlage, b.zustand, b.text, b.entscheid_text,
b.entschieden_am, b.aufgabe_id, e.name AS von_name
FROM vorlagen_bewerbungen b
LEFT JOIN personen e ON e.id = b.entschieden_von
WHERE b.person_id = ?
AND b.zustand IN ('angenommen','abgelehnt')
AND COALESCE(b.entschieden_am, '') >= ?
ORDER BY b.entschieden_am DESC`)
.all(person.id, vorTagen(ANTWORT_TAGE))
.map((z) => ({ ...z, titel: vorlageZuSchluessel(z.vorlage)?.titel || z.vorlage }));
} catch (fehler) {
console.error("[vorlagen] Antworten:", fehler?.message);
return [];
}
}
/** Was die Leitung schon abgelehnt hat -- aus BEIDEN Wegen.
*
* AUS BEIDEN, und das ist der Punkt. Man kann sich auf eine Vorlage
* bewerben und auf eine Aufgabe, die es schon gibt. Eine Uebersicht,
* die nur die Haelfte zeigt, ist schlimmer als keine: „Frida wurde
* nie abgelehnt" waere dann eine Auskunft, die stimmt und taeuscht.
*
* `entschieden_von IS NOT NULL` TRENNT ZWEI DINGE, die beide
* `abgelehnt` heissen:
* · die Leitung hat eine Bewerbung abgelehnt <- das hier
* · die Person hat eine Zuteilung abgelehnt <- ihre Entscheidung
* · „Von jemand anderem uebernommen" <- gar keine Absage
* Die letzten beiden tragen keinen Entscheider und fallen heraus.
*
* UND DIE HAUSTRENNUNG GILT: `darfSchreibenMit` -- wer den Menschen
* gar nicht sehen darf, sieht auch nicht, dass ihm abgesagt wurde.
*/
function absagenUebersicht(person) {
if (!entscheidetUeberAufgaben(person)) return null;
try {
const seit = vorTagen(ABSAGEN_TAGE);
const zeilen = db().prepare(`
SELECT 'vorlage' AS quelle, b.person_id, p.name AS person_name,
b.vorlage AS kennung, NULL AS aufgabe_id,
b.text AS wort, b.entscheid_text, b.entschieden_am,
e.name AS von_name
FROM vorlagen_bewerbungen b
LEFT JOIN personen p ON p.id = b.person_id
LEFT JOIN personen e ON e.id = b.entschieden_von
WHERE b.zustand = 'abgelehnt'
AND b.entschieden_von IS NOT NULL
AND COALESCE(b.entschieden_am, '') >= ?
UNION ALL
SELECT 'aufgabe', z.person_id, p.name,
a.titel, a.id,
z.grund, z.entscheid_text, z.entschieden_am,
e.name
FROM aufgaben_zuteilung z
JOIN aufgaben a ON a.id = z.aufgabe_id
LEFT JOIN personen p ON p.id = z.person_id
LEFT JOIN personen e ON e.id = z.entschieden_von
WHERE z.zustand = 'abgelehnt'
AND z.entschieden_von IS NOT NULL
AND COALESCE(z.entschieden_am, '') >= ?
ORDER BY entschieden_am DESC`).all(seit, seit);
const sichtbar = zeilen
.filter((z) => z.person_id && darfSchreibenMit(person, z.person_id))
.map((z) => ({
...z,
titel: z.quelle === "vorlage"
? (vorlageZuSchluessel(z.kennung)?.titel || z.kennung)
: z.kennung,
}));
/* WIE OFT JE MENSCH -- das ist VanVans eigentliche Frage. Eine
Liste von zwanzig Zeilen beantwortet „bewirbt sich jemand immer
wieder auf etwas, das wir immer wieder ablehnen?" nicht; eine
Zahl je Person schon.
ABGELEITET AUS DERSELBEN LISTE und nicht zweitgezaehlt: Zwei
Rechnungen fuer dieselbe Frage laufen auseinander. */
const jeMensch = new Map();
for (const z of sichtbar) {
const t = jeMensch.get(z.person_id)
|| { person_id: z.person_id, name: z.person_name, anzahl: 0, zuletzt: null };
t.anzahl += 1;
if (!t.zuletzt || String(z.entschieden_am) > t.zuletzt) t.zuletzt = z.entschieden_am;
jeMensch.set(z.person_id, t);
}
return {
seit_tagen: ABSAGEN_TAGE,
liste: sichtbar,
je_mensch: [...jeMensch.values()].sort((a, b) => b.anzahl - a.anzahl),
};
} catch (fehler) {
console.error("[vorlagen] Absagen:", fehler?.message);
return null;
}
}
/** Die offenen Bewerbungen, so wie sie die Seite braucht.
*
* WER WAS SIEHT, ENTSCHEIDET HIER DER SERVER: Wer entscheidet, sieht