diff --git a/server/pruef-uebergang.mjs b/server/pruef-uebergang.mjs new file mode 100644 index 00000000..6bc17383 --- /dev/null +++ b/server/pruef-uebergang.mjs @@ -0,0 +1,444 @@ +/* ===================================================================== + Der Übergang — Buddy und erste Schicht (15.09.2026) + + Filipe: „ich will dass du die 2 kategorien perfektionnierst. ich will + die noch viel besser, perfektionniert, viel geiler und krasser. es + soll so einfach wie möglich sein für jeden." + + --------------------------------------------------------------------- + WAS HIER GEPRÜFT WIRD UND WARUM + + In der Beschreibung der Stufe „Probe" stand seit dem ersten Tag + „fester Buddy". Ein Feld dafür gab es nicht. Genau an dieser Stelle + — zwischen dem „ja" und der ersten echten Schicht — verlieren Teams + die Leute, die sie gerade gewonnen haben. + + Ein Versprechen im Text ist keine Eigenschaft des Systems. Erst eine + Regel, die NEIN sagen kann, ist eine. Also: + + Kommt jemand ohne Buddy auf die Probe? (darf nicht) + Kommt jemand ohne Datum auf die Probe? (darf nicht) + Bleibt die Karte dabei stehen, wo sie war? (muss) + Überleben Buddy und Datum den Schritt ins Team? (müssen) + Fängt die Standzeit beim Ändern neu an? (darf nicht) + + Aufrufen mit: node server/pruef-uebergang.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4420, "pruef-uebergang"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-ueber-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "pruef-uebergang"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const tal = await import("./workspace-talent-punkte.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +/* Tagesdatum in Ortszeit -- dieselbe Rechnung wie im Server. Ein Test, + der in UTC rechnet, waere zwischen Mitternacht und zwei Uhr morgens + einen Tag daneben und wuerde nachts rot, ohne dass etwas kaputt ist. */ +const tag = (n = 0) => { + const d = new Date(); + d.setDate(d.getDate() + n); + const z = (x) => String(x).padStart(2, "0"); + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; +}; + +let idDogi, idHand, idFrida, idMara, idNina, idTalent, idZweit; +{ + const d = oeffne(); + const s = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", s, jetzt); + const anlegen = (name, rolle, code) => { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", s).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); + idFrida = anlegen("Frida", "modi", "CODE-MODI-0001"); + idNina = anlegen("Nina", "modi", "CODE-MODI-0002"); + /* EINE MANAGERIN ALS GEGENPROBE: Sie arbeitet im anderen Haus und + kann niemanden durch eine Schicht begleiten, die sie nicht macht. */ + idMara = anlegen("Mara", "manager", "CODE-MANA-0001"); + + const neuTalent = (titel) => { + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von, einsatz)" + + " VALUES ('talente','ruhig',?,?,?,?,?,?)") + .run(titel, "Faellt seit Wochen auf.", jetzt.slice(0, 10), jetzt, idDogi, "Dienstag im Stream"); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idTalent = neuTalent("Kessi aus dem Chat"); + idZweit = neuTalent("Bo aus dem Chat"); + d.close(); +} + +function ruf(pfad, host, methode, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } }, + (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await ruf("/workspace/api/anmelden", host, "POST", {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +ok(!!kDogi && !!kHand, "DogFather und die rechte Hand sind angemeldet"); + +const lage = () => ruf("/workspace/api/talente/lage", HAUS, "GET", { cookie: kDogi }).then(json); +const karteVon = async (id) => (await lage()).kandidaten.find((k) => k.id === id); +const stufeSetzen = (id, koerper, keks = kDogi) => + ruf(`/workspace/api/talente/${id}/stufe`, HAUS, "PUT", { cookie: keks }, koerper); + +/* ======================================================================= + 1. WER EINARBEITEN KANN — und wer nicht + ======================================================================= */ +melde(""); +melde("=== 1. Wer einarbeiten kann ==="); +{ + const l = await lage(); + const namen = (l.buddys || []).map((b) => b.name); + ok(namen.length === 4, `${namen.length} Menschen können einarbeiten`); + /* DIE REIHENFOLGE DES HAUSES, nicht die der Datenbank: DogFather, + dann die rechte Hand, dann die Modis -- und innerhalb einer Rolle + nach Namen. */ + ok(namen.join(", ") === "Filipe, Rieke, Frida, Nina", + `in der Reihenfolge des Hauses (${namen.join(", ")})`); + ok(!namen.includes("Mara"), + "eine Managerin nicht — sie arbeitet im anderen Haus"); + ok((l.erster_tag || []).length === tal.ERSTER_TAG.length, + `und am ersten Tag gibt es ${l.erster_tag?.length} Dinge, nicht dreißig`); + ok(l.schicht_weit_tage === tal.SCHICHT_WEIT_TAGE, + `ab ${l.schicht_weit_tage} Tagen gilt ein Termin als weit weg`); +} + +/* ======================================================================= + 2. OHNE BUDDY UND DATUM KEINE PROBE + ======================================================================= */ +melde(""); +melde("=== 2. Ohne die zwei geht es nicht ==="); +{ + await stufeSetzen(idTalent, { stufe: "beobachtet" }); + await stufeSetzen(idTalent, { stufe: "angesprochen" }); + const vorher = await karteVon(idTalent); + ok(vorher.stufe === "angesprochen", `die Karte steht auf „${vorher.stufe_name}“`); + + const leer = await stufeSetzen(idTalent, { stufe: "probe" }); + ok(leer.code === 400, `ohne alles geht die Probe nicht los (${leer.code})`); + const f = json(leer).fehlt || []; + ok(f.includes("buddy") && f.includes("erste_schicht"), + `und die Absage nennt beides (${f.join(", ")})`); + + /* DAS EIGENTLICHE: DIE KARTE DARF SICH NICHT BEWEGT HABEN. Eine + Absage, nach der die Stufe trotzdem steht, ist schlimmer als keine + -- dann steht „Probe" da und niemand arbeitet jemanden ein. */ + const danach = await karteVon(idTalent); + ok(danach.stufe === "angesprochen", + `die Karte steht unverändert auf „${danach.stufe_name}“`); + ok(danach.uebergang === null, "und es steht nichts halb Ausgefülltes da"); + + const halb = await stufeSetzen(idTalent, { stufe: "probe", buddy_id: idFrida }); + ok(halb.code === 400 && (json(halb).fehlt || []).includes("erste_schicht"), + "mit Buddy, aber ohne Datum: auch nicht"); + const halb2 = await stufeSetzen(idTalent, { stufe: "probe", erste_schicht: tag(3) }); + ok(halb2.code === 400 && (json(halb2).fehlt || []).includes("buddy"), + "mit Datum, aber ohne Buddy: auch nicht"); +} + +/* ======================================================================= + 3. UND WAS ALS BUDDY ODER DATUM NICHT GILT + ======================================================================= */ +melde(""); +melde("=== 3. Was nicht gilt ==="); +{ + const fremd = await stufeSetzen(idTalent, + { stufe: "probe", buddy_id: idMara, erste_schicht: tag(3) }); + ok(fremd.code === 400 && (json(fremd).fehlt || []).includes("buddy_unbekannt"), + "eine Managerin als Buddy: abgelehnt"); + + const wort = await stufeSetzen(idTalent, + { stufe: "probe", buddy_id: idFrida, erste_schicht: "nächsten Samstag" }); + ok(wort.code === 400 && (json(wort).fehlt || []).includes("datum_unlesbar"), + "„nächsten Samstag“ ist kein Datum"); + + const alt = await stufeSetzen(idTalent, + { stufe: "probe", buddy_id: idFrida, erste_schicht: tag(-30) }); + ok(alt.code === 400 && (json(alt).fehlt || []).includes("datum_zu_alt"), + "ein Datum von vor dreißig Tagen: abgelehnt"); + + const weit = await stufeSetzen(idTalent, + { stufe: "probe", buddy_id: idFrida, erste_schicht: tag(tal.SCHICHT_MAX_TAGE + 5) }); + ok(weit.code === 400 && (json(weit).fehlt || []).includes("datum_zu_weit"), + `weiter als ${tal.SCHICHT_MAX_TAGE} Tage: kein Termin mehr`); + + /* GEGENPROBE ZUM RÜCKWÄRTS-DATUM: Wer eine laufende Probe nachträgt, + muss das Datum von damals eintragen können -- sonst schreibt er + heute hinein, und die Standzeit stimmt ab da nie wieder. */ + const gestern = await stufeSetzen(idTalent, + { stufe: "probe", buddy_id: idFrida, erste_schicht: tag(-3) }); + ok(gestern.code === 200, `drei Tage rückwärts geht (${gestern.code})`); + const k = await karteVon(idTalent); + ok(k.uebergang?.bis_schicht === -3, + `und die Karte weiß, dass es drei Tage her ist (${k.uebergang?.bis_schicht})`); + ok(k.uebergang?.satz === "Erste Schicht war vor 3 Tagen.", + `in einem Satz, den man laut vorlesen kann: „${k.uebergang?.satz}“`); +} + +/* ======================================================================= + 4. MIT BEIDEN: DIE PROBE LÄUFT + ======================================================================= */ +melde(""); +melde("=== 4. Die Probe läuft ==="); +{ + const r = await stufeSetzen(idZweit, { stufe: "beobachtet" }); + await stufeSetzen(idZweit, { stufe: "angesprochen" }); + const los = await stufeSetzen(idZweit, + { stufe: "probe", buddy_id: idHand, erste_schicht: tag(2) }); + ok(r.code === 200 && los.code === 200, `mit Buddy und Datum geht es (${los.code})`); + + const k = await karteVon(idZweit); + ok(k.stufe === "probe", `die Karte steht auf „${k.stufe_name}“`); + ok(k.uebergang?.buddy_name === "Rieke", `Rieke arbeitet ein (${k.uebergang?.buddy_name})`); + ok(k.uebergang?.erste_schicht === tag(2), `erste Schicht am ${k.uebergang?.erste_schicht}`); + ok(k.uebergang?.satz === "Erste Schicht in 2 Tagen.", `„${k.uebergang?.satz}“`); + ok(k.uebergang?.buddy_weg === false, "und der Buddy ist da"); + + /* EIN WEIT ENTFERNTER TERMIN WIRD NICHT VERBOTEN, ABER GESAGT. */ + const weit = await ruf(`/workspace/api/talente/${idZweit}/uebergang`, HAUS, "PUT", + { cookie: kDogi }, { buddy_id: idHand, erste_schicht: tag(tal.SCHICHT_WEIT_TAGE + 6) }); + ok(weit.code === 200, `ein Termin in drei Wochen ist erlaubt (${weit.code})`); + const kw = await karteVon(idZweit); + ok(/weit weg/.test(kw.uebergang?.satz || ""), + `aber die Karte sagt es: „${(kw.uebergang?.satz || "").slice(0, 60)}…“`); +} + +/* ======================================================================= + 5. ÄNDERN SETZT DIE STANDZEIT NICHT ZURÜCK + ======================================================================= */ +melde(""); +melde("=== 5. Ändern ist kein Neuanfang ==="); +{ + /* Die Standzeit einer Karte ist die Auskunft ueber UNS. Wer sie + durch ein Aendern des Buddys zurueckstellt, loescht genau die + Verschleppung, die sichtbar werden soll. */ + const d = oeffne(); + d.prepare("UPDATE talent_stufe SET seit = ? WHERE eintrag_id = ?") + .run(new Date(Date.now() - 40 * 86400000).toISOString(), idZweit); + d.close(); + + const vorher = await karteVon(idZweit); + ok(vorher.tage === 40, `die Karte liegt seit ${vorher.tage} Tagen auf der Probe`); + ok(vorher.liegt === true, "und gilt damit als liegengeblieben"); + + const r = await ruf(`/workspace/api/talente/${idZweit}/uebergang`, HAUS, "PUT", + { cookie: kDogi }, { buddy_id: idNina, erste_schicht: tag(1) }); + ok(r.code === 200, `Buddy und Termin ändern geht (${r.code})`); + const nach = await karteVon(idZweit); + ok(nach.uebergang?.buddy_name === "Nina", `jetzt arbeitet Nina ein (${nach.uebergang?.buddy_name})`); + ok(nach.tage === 40, `und die Standzeit steht unverändert bei ${nach.tage} Tagen`); + ok(nach.stufe === "probe", "die Stufe hat sich dabei nicht bewegt"); + + /* DIE ABSAGE GILT AUCH HIER -- eine zweite Tür mit weniger Regeln + wäre genau die, durch die es irgendwann durchgeht. */ + const leer = await ruf(`/workspace/api/talente/${idZweit}/uebergang`, HAUS, "PUT", + { cookie: kDogi }, {}); + ok(leer.code === 400, `auch hier geht es nicht ohne die zwei (${leer.code})`); + const immerNoch = await karteVon(idZweit); + ok(immerNoch.uebergang?.buddy_name === "Nina", "und nichts wurde dabei gelöscht"); +} + +/* ======================================================================= + 6. DER SCHRITT INS TEAM NIMMT BEIDES MIT + ======================================================================= */ +melde(""); +melde("=== 6. Ins Team ==="); +{ + const r = await stufeSetzen(idZweit, { stufe: "imteam" }); + ok(r.code === 200, `ins Team geht ohne die zwei Felder (${r.code})`); + const k = await karteVon(idZweit); + ok(k.stufe === "imteam", `die Karte steht auf „${k.stufe_name}“`); + /* SIE SIND DIE GESCHICHTE DIESER PROBE, kein Formularfeld. Wer sie + beim letzten Schritt verliert, weiss hinterher nicht mehr, wer + eingearbeitet hat. */ + ok(k.uebergang?.buddy_name === "Nina", + `und weiß weiterhin, wer eingearbeitet hat (${k.uebergang?.buddy_name})`); + ok(k.uebergang?.erste_schicht === tag(1), "auch die erste Schicht steht noch da"); +} + +/* ======================================================================= + 7. WENN DER BUDDY GEHT + ======================================================================= */ +melde(""); +melde("=== 7. Wenn der Buddy geht ==="); +{ + const d = oeffne(); + d.prepare("UPDATE personen SET aktiv = 0 WHERE id = ?").run(idFrida); + d.close(); + const k = await karteVon(idTalent); + ok(k.uebergang?.buddy_weg === true, + "ein stillgelegter Zugang zählt nicht mehr als Buddy"); + ok(k.uebergang?.buddy_name === "Frida", + "der Name bleibt trotzdem stehen — sonst weiß niemand, wer es war"); + + /* UND WER GELÖSCHT WIRD, NIMMT DIE PROBE EINES ANDEREN NICHT MIT. */ + const d2 = oeffne(); + d2.prepare("DELETE FROM personen WHERE id = ?").run(idFrida); + const uebrig = d2.prepare("SELECT buddy_id, erste_schicht, stufe FROM talent_stufe WHERE eintrag_id = ?") + .get(idTalent); + d2.close(); + ok(uebrig?.stufe === "probe", "die Karte steht weiterhin auf der Probe"); + ok(uebrig?.buddy_id === null, "nur der Buddy ist leer — nicht die Karte"); + ok(!!uebrig?.erste_schicht, "und das Datum steht noch da"); + const k2 = await karteVon(idTalent); + ok(k2.uebergang?.buddy_weg === true && k2.uebergang?.buddy_name === null, + "die Karte sagt jetzt: bitte neu bestimmen"); +} + +/* ======================================================================= + 8. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 8. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); + await kontext.addCookies(kDogi.split("; ").filter(Boolean).map((teil) => { + const i = teil.indexOf("="); + return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/talente.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const stand = await seite.evaluate(() => { + const karten = [...document.querySelectorAll(".t-karte")]; + const probe = karten.find((k) => k.dataset.stufe === "probe"); + return { + karten: karten.length, + bloecke: document.querySelectorAll(".t-uebergang").length, + wer: probe?.querySelector(".t-uebergang__wer")?.textContent || "", + satz: probe?.querySelector(".t-uebergang__satz")?.textContent || "", + ersterTag: probe?.querySelectorAll(".t-uebergang__punkt").length || 0, + aendern: !!probe?.querySelector(".t-uebergang__aendern"), + weg: probe?.querySelector(".t-uebergang")?.dataset.weg || "", + }; + }); + ok(stand.karten === 2, `${stand.karten} Karten stehen auf der Seite`); + ok(stand.bloecke >= 1, `${stand.bloecke} davon zeigen Buddy und Termin`); + ok(/neu bestimmen/.test(stand.wer), + `und die Karte ohne Buddy sagt es im Klartext: „${stand.wer}“`); + ok(stand.weg === "ja", "sie ist außerdem am Rand markiert"); + ok(stand.ersterTag === tal.ERSTER_TAG.length, + `die drei Dinge für den ersten Tag stehen dabei (${stand.ersterTag})`); + ok(stand.aendern === true, "und ein Knopf, um beides zu ändern"); + + /* DAS FRAGEFELD: Es muss aufgehen, BEVOR die Stufe sich bewegt. */ + await seite.goto(`http://127.0.0.1:${PORT}/workspace/talente.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + const frage = await seite.evaluate(async () => { + const karten = [...document.querySelectorAll(".t-karte")]; + const probe = karten.find((k) => k.dataset.stufe === "probe"); + probe?.querySelector(".t-uebergang__aendern")?.click(); + await new Promise((r) => setTimeout(r, 200)); + const f = document.querySelector(".t-uebergang--frage"); + return { + da: !!f, + auswahl: f?.querySelectorAll("select option").length || 0, + datumsfeld: f?.querySelector('input[type="date"]')?.type || "", + vorbelegt: f?.querySelector("select")?.value || "", + }; + }); + ok(frage.da === true, "der Knopf öffnet das Fragefeld"); + ok(frage.auswahl === 4, `mit drei Namen zur Auswahl und einem leeren (${frage.auswahl})`); + ok(frage.datumsfeld === "date", + "und einem echten Datumsfeld — auf dem Handy also dem Kalender des Geräts"); + /* KEINER IST VORAUSGEWÄHLT, WENN ES KEINEN GIBT. Diese Karte hat + ihren Buddy verloren -- ein vorausgefülltes Feld wäre hier das + Gegenteil von hilfreich: Man klickt sich durch und hat am Ende + jemanden bestimmt, ohne ihn gefragt zu haben. */ + ok(frage.vorbelegt === "", "und keiner ist vorausgewählt — hier muss jemand wirklich wählen"); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + await browser.close(); +} + +/* ======================================================================= + 9. DER TEXT SAGT, WAS DAS SYSTEM TUT + ======================================================================= */ +melde(""); +melde("=== 9. Text und Verhalten stimmen überein ==="); +{ + const probe = tal.TALENT_STUFEN.find((s) => s.schluessel === "probe"); + ok(/Buddy/.test(probe.bedeutet), + "in der Stufenbeschreibung steht weiterhin „fester Buddy“"); + const angesprochen = tal.TALENT_STUFEN.find((s) => s.schluessel === "angesprochen"); + ok(/erste Schicht/.test(angesprochen.schritt), + `und der Schritt davor nennt sie beim Namen: „${angesprochen.schritt.slice(0, 60)}…“`); + + const js = readFileSync(new URL("../workspace/assets/js/talente.js", import.meta.url), "utf8"); + ok(/uebergangFragen/.test(js), "die Oberfläche fragt beides vorher ab"); + /* UND SIE VERLÄSST SICH NICHT DARAUF. Was nur im Browser steht, gilt + nur für den, der den Browser benutzt -- Abschnitt 2 hat es an der + Schnittstelle nachgemessen. */ + ok(/t-uebergang__aendern/.test(js), "und lässt beides später ändern"); +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-entwicklung.js b/server/workspace-entwicklung.js index 56d6bea2..4b42e4cc 100644 --- a/server/workspace-entwicklung.js +++ b/server/workspace-entwicklung.js @@ -70,6 +70,7 @@ import { } from "./workspace-entwicklungs-punkte.js"; import { TALENT_STUFEN, TALENT_GRUPPEN, TALENT_MERKMALE, TALENT_WARNZEICHEN, + SCHICHT_WEIT_TAGE, SCHICHT_MAX_TAGE, ERSTER_TAG, TALENT_NACH_SCHLUESSEL, talentStufe, talentNaechste, ansprechtext, } from "./workspace-talent-punkte.js"; @@ -92,6 +93,15 @@ export const VERBLASST_NACH_TAGEN = 90; automatisch eine Personalakte bekommen. */ const KARTEN_ROLLEN = ["hand", "modi"]; +/** Wer einen Neuen einarbeiten kann. + * + * DIESELBEN WIE BEI DER KARTE, PLUS DOGFATHER. Er steht nicht ueber + * dem Team, er gehoert dazu -- und wer eine Probe begleitet, macht + * dieselbe Arbeit wie alle anderen. Ein Manager oder Scout dagegen + * arbeitet im anderen Haus und kann niemanden durch eine Schicht + * begleiten, die er selbst nicht macht. */ +const BUDDY_ROLLEN = ["admin", "hand", "modi"]; + /* ===================================================================== DIE TABELLEN ===================================================================== */ @@ -155,6 +165,30 @@ export function tabellen() { notiz TEXT ); `); + + /* DIE BEIDEN SPALTEN DES ÜBERGANGS (15.09.2026). + + Sie stehen hier und nicht in der Umstellungsliste von workspace.js: + Die ueberspringt eine Tabelle stillschweigend, wenn es sie noch + nicht gibt -- und talent_stufe entsteht erst beim ersten Aufruf + dieser Seite, nicht beim Start. Hier, zwei Zeilen nach dem CREATE, + ist sie sicher da. + + ON DELETE SET NULL beim Buddy: Wer geht, nimmt die Probe eines + anderen nicht mit. Die Karte verliert den Namen und faellt damit + genau dort auf, wo jemand einen neuen Buddy bestimmen muss. */ + for (const [spalte, typ] of [ + ["buddy_id", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"], + ["erste_schicht", "TEXT"], + ]) { + try { + const da = db().prepare("PRAGMA table_info(talent_stufe)").all().map((s) => s.name); + if (!da.includes(spalte)) { + db().exec(`ALTER TABLE talent_stufe ADD COLUMN ${spalte} ${typ}`); + console.log(`[talente] Spalte '${spalte}' ergaenzt.`); + } + } catch (f) { console.error(`[talente] Spalte '${spalte}':`, f?.message); } + } bereit = true; } @@ -618,9 +652,72 @@ entwicklungRouter.get("/workspace/api/entwicklung/ampel", nurLeitung, (req, res) TALENTE ===================================================================== */ +/** Ein Tagesdatum in Ortszeit, YYYY-MM-DD. + * + * NICHT toISOString().slice(0,10) -- das rechnet in UTC, und zwischen + * Mitternacht und zwei Uhr morgens liefert es in Deutschland den + * Vortag. Genau diese Zeile steht als offener Befund in pruef-struktur. */ +function tagIn(d = new Date()) { + return new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin" }).format(d); +} + +/** Wie viele Tage liegen zwischen heute und einem Tagesdatum? + * Negativ heisst: vorbei. */ +function tageBis(tag) { + if (!tag) return null; + const heute = Date.parse(tagIn() + "T12:00:00Z"); + const ziel = Date.parse(tag + "T12:00:00Z"); + if (!Number.isFinite(ziel)) return null; + return Math.round((ziel - heute) / 86400000); +} + +/** Der Übergang: ein Buddy und ein Datum — mehr braucht die Probe nicht. + * + * ER WIRD ABGELEITET, NICHT GESPEICHERT. Ein Satz wie „die erste + * Schicht war vor drei Tagen" in der Datenbank waere ab morgen falsch. + * Aus zwei Feldern wird hier jedes Mal neu gerechnet, was gerade gilt. */ +function uebergangVon(stand) { + if (!stand?.erste_schicht && !stand?.buddy_id) return null; + const buddy = stand.buddy_id + ? db().prepare("SELECT name, rolle, aktiv FROM personen WHERE id = ?").get(stand.buddy_id) + : null; + const bis = tageBis(stand.erste_schicht); + return { + buddy_id: stand.buddy_id || null, + buddy_name: buddy?.name || null, + /* WER GEGANGEN IST, IST KEIN BUDDY MEHR -- und zwar auf beiden + Wegen, auf denen jemand gehen kann. + + Der erste Entwurf fragte "`buddy_id` gesetzt UND die Person weg". + Damit war der zweite Weg blind: Wird ein Mensch GELOESCHT, setzt + ON DELETE SET NULL die Spalte auf NULL -- die Bedingung war + falsch, die Karte sah unauffaellig aus, und eine laufende Probe + stand ohne jeden Buddy da. Gefunden hat das die Pruefung, nicht + das Lesen; sie loescht die Person wirklich. + + Richtig ist die Frage, auf die es ankommt: Gibt es jemanden, der + gerade einarbeitet? */ + buddy_weg: !buddy?.aktiv, + erste_schicht: stand.erste_schicht || null, + bis_schicht: bis, + /* Drei Lagen, drei Saetze -- derselbe Grund wie bei der Ampel: + ein Satz fuer alle ergibt bei der Haelfte falsches Deutsch. */ + satz: !stand.erste_schicht ? null + : bis > SCHICHT_WEIT_TAGE + ? `Erste Schicht in ${bis} Tagen. Das ist weit weg – bis dahin hört man nichts ` + + "voneinander, und genau da springen die meisten ab." + : bis > 1 ? `Erste Schicht in ${bis} Tagen.` + : bis === 1 ? "Erste Schicht ist morgen." + : bis === 0 ? "Erste Schicht ist heute." + : bis === -1 ? "Erste Schicht war gestern." + : `Erste Schicht war vor ${-bis} Tagen.`, + }; +} + /** Die Karte eines Kandidaten -- Stufe, Standzeit, was zu tun ist. */ function talentKarte(e, merkmale) { - const stand = db().prepare("SELECT stufe, seit, notiz FROM talent_stufe WHERE eintrag_id = ?") + const stand = db().prepare( + "SELECT stufe, seit, notiz, buddy_id, erste_schicht FROM talent_stufe WHERE eintrag_id = ?") .get(e.id); const stufe = talentStufe(stand?.stufe) || TALENT_STUFEN[0]; const seit = stand?.seit || e.erstellt; @@ -640,6 +737,10 @@ function talentKarte(e, merkmale) { merkmale: gesetzt.filter((s) => !TALENT_NACH_SCHLUESSEL.get(s)?.warnung), warnzeichen: warn, notiz: stand?.notiz || null, + /* DER ÜBERGANG. Er steht an der Karte und nicht in einem eigenen + Abruf: Er aendert sich mit jedem Tag, und was man extra holen + muss, ist beim Holen schon veraltet. */ + uebergang: uebergangVon(stand), /* DER VORLAGENTEXT -- gebaut aus den Merkmalen, die wirklich angeklickt wurden. Er kommt MIT der Karte und nicht ueber einen eigenen Weg: Er aendert sich mit jedem Klick, und ein Text, den @@ -665,6 +766,15 @@ entwicklungRouter.get("/workspace/api/talente/lage", (req, res) => { stufen: TALENT_STUFEN, gruppen: TALENT_GRUPPEN.map((g) => ({ ...g, merkmale: TALENT_MERKMALE[g.schluessel] || [] })), warnzeichen: TALENT_WARNZEICHEN, + /* WER BUDDY SEIN KANN -- in der Reihenfolge des Hauses, nicht in + der der Datenbank. Sie kommt mit der Lage, damit die Auswahl + keine zweite Liste braucht, die man pflegen muesste. */ + buddys: db().prepare( + `SELECT id, name, rolle FROM personen + WHERE aktiv = 1 AND rolle IN (${BUDDY_ROLLEN.map(() => "?").join(", ")}) + ORDER BY ${ROLLEN_SORTIERUNG}, name COLLATE NOCASE`).all(...BUDDY_ROLLEN), + erster_tag: ERSTER_TAG, + schicht_weit_tage: SCHICHT_WEIT_TAGE, kandidaten: karten, /* Wie viele auf jeder Stufe liegen -- der Trichter als Zahlen. Abgeleitet, damit er nicht danebenliegen kann. */ @@ -713,6 +823,67 @@ entwicklungRouter.put("/workspace/api/talente/:id/merkmal/:schluessel", gleicheH } }); +/** Buddy und Datum prüfen. Gibt `{ fehlt: [...] }` oder `{ buddyId, schicht }`. + * + * EINE STELLE, ZWEI AUFRUFER: der Stufenwechsel auf „Probe" und das + * spaetere Aendern. Zwei Abschriften derselben Regeln waeren zwei + * Regeln, und die zweite ist immer die, die etwas durchlaesst. */ +function uebergangPruefen(koerper, { pflicht }) { + const fehlt = []; + const buddyId = nummer(koerper?.buddy_id); + const b = buddyId + ? db().prepare("SELECT id, aktiv, rolle FROM personen WHERE id = ?").get(buddyId) : null; + if (buddyId && (!b || !b.aktiv || !BUDDY_ROLLEN.includes(b.rolle))) fehlt.push("buddy_unbekannt"); + else if (!buddyId && pflicht) fehlt.push("buddy"); + + const roh = String(koerper?.erste_schicht || "").trim(); + if (roh) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(roh)) fehlt.push("datum_unlesbar"); + else { + const bis = tageBis(roh); + /* VERGANGENHEIT IST KEIN TERMIN. Wer eine Schicht nachtraegt, die + schon war, meint etwas anderes -- naemlich, dass die Probe + laeuft. Dann gehoert das Datum von damals hinein, nicht heute: + deshalb sind bis zu sieben Tage rueckwaerts erlaubt. */ + if (bis === null) fehlt.push("datum_unlesbar"); + else if (bis < -7) fehlt.push("datum_zu_alt"); + else if (bis > SCHICHT_MAX_TAGE) fehlt.push("datum_zu_weit"); + } + } else if (pflicht) fehlt.push("erste_schicht"); + + return fehlt.length ? { fehlt } : { buddyId: buddyId || null, schicht: roh || null }; +} + +/** Buddy oder Datum ändern, ohne die Stufe anzufassen. + * + * Ein eigener Weg, weil es ein eigener Vorgang ist: Ein Buddy hoert + * auf, ein Termin verschiebt sich. Wer dafuer die Stufe neu setzen + * muesste, setzt dabei `seit` zurueck -- und die Standzeit, die genau + * diese Verschleppung sichtbar macht, faengt wieder bei null an. */ +entwicklungRouter.put("/workspace/api/talente/:id/uebergang", gleicheHerkunft, (req, res) => { + try { + const id = nummer(req.params.id); + const e = id && db().prepare("SELECT id, bereich, titel FROM eintraege WHERE id = ?").get(id); + if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" }); + const stand = db().prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(id); + if (!stand) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const p = uebergangPruefen(req.body, { pflicht: stand.stufe === "probe" }); + if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt }); + + db().prepare("UPDATE talent_stufe SET buddy_id = ?, erste_schicht = ? WHERE eintrag_id = ?") + .run(p.buddyId, p.schicht, id); + protokolliere("talent_uebergang", { + personId: req.person.id, ip: echteIp(req), + detail: `${e.titel}: Buddy/erste Schicht geaendert`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (f) { + console.error("[talente] Uebergang:", f?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + entwicklungRouter.put("/workspace/api/talente/:id/stufe", gleicheHerkunft, (req, res) => { try { const id = nummer(req.params.id); @@ -722,12 +893,35 @@ entwicklungRouter.put("/workspace/api/talente/:id/stufe", gleicheHerkunft, (req, if (!e || e.bereich !== "talente") return res.status(404).json({ fehler: "nicht_gefunden" }); if (!talentStufe(stufe)) return res.status(400).json({ fehler: "unbekannte_stufe" }); - db().prepare(`INSERT INTO talent_stufe (eintrag_id, stufe, seit, von_id, notiz) - VALUES (?,?,?,?,?) + /* AUF DIE PROBE KOMMT NIEMAND OHNE BUDDY UND DATUM. + + In der Stufenbeschreibung stand „fester Buddy" seit dem ersten + Tag -- ein Feld dafuer gab es nicht. Ein Versprechen ohne Feld + ist eines, an das sich nach zwei Wochen niemand erinnert, und + genau an dieser Stelle verlieren Teams die Leute, die sie gerade + gewonnen haben. + + EINE ABSAGE MIT GRUND, nicht ein „ging nicht": Die Oberflaeche + fragt beides vorher ab, aber die Regel gehoert hierher. Was nur + im Browser steht, gilt nur fuer den, der den Browser benutzt. */ + const p = stufe === "probe" + ? uebergangPruefen(req.body, { pflicht: true }) + : uebergangPruefen(req.body, { pflicht: false }); + if (p.fehlt) return res.status(400).json({ fehler: "uebergang_offen", fehlt: p.fehlt }); + + /* COALESCE, NICHT ÜBERSCHREIBEN: Wer aus der Probe ins Team geht, + schickt Buddy und Datum nicht noch einmal mit -- und die beiden + sollen bleiben, wo sie sind. Sie sind die Geschichte dieser + Probe, nicht ein Formularfeld. */ + db().prepare(`INSERT INTO talent_stufe + (eintrag_id, stufe, seit, von_id, notiz, buddy_id, erste_schicht) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(eintrag_id) DO UPDATE SET stufe = excluded.stufe, seit = excluded.seit, - von_id = excluded.von_id, notiz = excluded.notiz`) - .run(id, stufe, jetzt(), req.person.id, notiz || null); + von_id = excluded.von_id, notiz = excluded.notiz, + buddy_id = COALESCE(excluded.buddy_id, talent_stufe.buddy_id), + erste_schicht = COALESCE(excluded.erste_schicht, talent_stufe.erste_schicht)`) + .run(id, stufe, jetzt(), req.person.id, notiz || null, p.buddyId, p.schicht); protokolliere("talent_stufe", { personId: req.person.id, ip: echteIp(req), diff --git a/server/workspace-talent-punkte.js b/server/workspace-talent-punkte.js index 69aa4547..d3cc797e 100644 --- a/server/workspace-talent-punkte.js +++ b/server/workspace-talent-punkte.js @@ -67,7 +67,19 @@ export const TALENT_STUFEN = [ { schluessel: "angesprochen", name: "Angesprochen", rang: 3, frist: 7, bedeutet: "Gefragt, ob überhaupt Interesse besteht.", - schritt: "Antwort eintragen: ja, nein oder später.", + /* HIER GEHEN DIE MEISTEN VERLOREN -- nicht bei der Auswahl. + + Die Quellen zur Gewinnung von Moderatoren und Freiwilligen sagen + es uebereinstimmend: Der groesste Abbruch liegt zwischen der + Zusage und dem tatsaechlichen ersten Einsatz. Wer "ja" sagt und + danach zwei Wochen nichts hoert, hat innerlich schon abgesagt -- + und niemand merkt es, weil auf der Karte "angesprochen" steht + und alles in Ordnung aussieht. + + Deshalb steht hier nicht mehr "Antwort eintragen", sondern der + naechste Schritt mit Datum. */ + schritt: "Beim Ja gleich zwei Dinge festmachen: wer ihn einarbeitet " + + "und wann die erste Schicht ist. Nicht „melden wir uns“.", knopf: "Auf Probe nehmen", }, { @@ -319,3 +331,70 @@ export function talentNaechste(s) { if (i < 0 || i + 1 >= TALENT_STUFEN.length) return null; return TALENT_STUFEN[i + 1]; } + +/* ===================================================================== + DER ÜBERGANG (15.09.2026) + + Filipe: „ich will dass du die 2 kategorien perfektionnierst. […] es + soll so einfach wie möglich sein für jeden." + + --------------------------------------------------------------------- + WAS DIE RECHERCHE ERGEBEN HAT + + Die Auswahl war der gut gebaute Teil: 21 Merkmale, sechs + Warnzeichen, ein Trichter. Die Stelle, an der Teams tatsaechlich + Leute verlieren, liegt dahinter -- zwischen dem „ja" und dem ersten + echten Einsatz. + + 1. EIN FESTER BUDDY ist die am besten belegte Einzelmassnahme. + Discord nennt beide Formen (Buddy und Mentoring) als die + Trainingswege, die funktionieren; aus der Freiwilligenarbeit + kommt dieselbe Aussage mit Zahlen dahinter. + + 2. DER ABSTAND ZWISCHEN ZUSAGE UND ERSTEM EINSATZ entscheidet. + Je laenger er ist, desto mehr springen ab -- ohne abzusagen. + + 3. AM ERSTEN TAG BRAUCHT JEMAND DREI DINGE, nicht dreissig: was + gilt, was er darf, und wen er fragt. + + In der Stufenbeschreibung stand „fester Buddy" seit dem ersten Tag. + Ein Feld dafuer gab es nicht. Ein Versprechen ohne Feld ist ein + Versprechen, an das sich niemand erinnert -- deshalb verlangt die + Probe jetzt beides: einen Namen und ein Datum. + + Quellen: + https://discord.com/safety/training-and-onboarding-new-moderators + https://support.reddithelp.com/hc/en-us/articles/15484067270932-Recruiting-new-moderators + https://www.volunteermatters.com/blog/volunteer-onboarding + https://volunteerhub.com/blog/how-to-build-a-volunteer-onboarding-process-that-improves-retention + ===================================================================== */ + +/** Ab wann die erste Schicht „weit weg" ist. + * + * Vierzehn Tage. Nicht als Verbot -- manchmal ist jemand im Urlaub -- + * sondern als Satz auf der Karte. Wer laenger plant, soll es bewusst + * tun und nicht, weil gerade niemand nachgesehen hat. */ +export const SCHICHT_WEIT_TAGE = 14; + +/** So weit darf ein Datum hoechstens in der Zukunft liegen. + * + * Neunzig Tage sind kein Termin mehr, sondern eine Absichtserklaerung. + * Und es faengt den Tippfehler ab, der aus 2026 eine 2062 macht. */ +export const SCHICHT_MAX_TAGE = 90; + +/** Was jemand am ersten Tag bekommt. + * + * DREI DINGE, NICHT DREISSIG. Das ist die Kurzfassung dessen, was + * Discord seinen Servern empfiehlt -- und es ist bewusst eine feste + * Liste ohne Haekchen: Sie steht auf der Karte, damit der Buddy sie + * liest, und nicht damit jemand sie abarbeitet und abhakt. Eine + * Checkliste daneben waere die zweite Liste zu der, die es im + * Wissensbereich schon gibt. */ +export const ERSTER_TAG = [ + { titel: "Was gilt", + text: "Die Regeln des Treffs – und was passiert, wenn jemand sie bricht." }, + { titel: "Was du darfst", + text: "Welche Knöpfe da sind, welche noch nicht, und ab wann." }, + { titel: "Wen du fragst", + text: "Der Buddy. Ein Name, nicht „das Team“ – sonst fragt man niemanden." }, +]; diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index e0cfa737..3717c213 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609151345", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151355", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609151345", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151355", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151345", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151355", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151345", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151355", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/entwicklung.css b/workspace/assets/css/entwicklung.css index e04885c9..479cceb9 100644 --- a/workspace/assets/css/entwicklung.css +++ b/workspace/assets/css/entwicklung.css @@ -499,3 +499,82 @@ .b-verlauf__zeile { grid-template-columns: minmax(9ch, 1fr) repeat(auto-fit, minmax(2rem, 2rem)); } .b-verlauf__punkt { width: 1.9rem; } } + +/* ===================================================================== + DER ÜBERGANG — Buddy und erste Schicht (15.09.2026) + + Er steht unter dem nächsten Schritt und sieht anders aus als der + Rest der Karte: ein durchgehender linker Rand statt eines Kastens. + Während einer Probe ist er die Karte, nicht eine Angabe darauf. + + Augenschonend heißt hier vor allem: keine zweite Signalfarbe. Das + Orange auf dieser Seite bedeutet „liegt zu lange" — wenn der + Übergang es auch benutzt, bedeutet es bald gar nichts mehr. + ===================================================================== */ +.t-uebergang { + margin: 11px 0 0; padding: 12px 14px; + background: color-mix(in srgb, var(--tinte) 90%, #ffffff 10%); + border: 1px solid var(--rand); + border-left: 3px solid color-mix(in srgb, #668e6e 55%, transparent); +} +/* Kein Buddy mehr: die einzige Lage, die eine Farbe verdient. */ +.t-uebergang[data-weg="ja"] { border-left-color: #cc9451; } + +.t-uebergang__titel { + margin: 0 0 5px; font-size: .95rem; font-weight: 640; color: var(--text); +} +.t-uebergang__zeile { + margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; +} +.t-uebergang__was { + font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; + color: var(--text-still); +} +.t-uebergang__wer { font-size: .9rem; font-weight: 620; color: var(--text); } +.t-uebergang[data-weg="ja"] .t-uebergang__wer { color: #d8a76a; font-weight: 500; } + +.t-uebergang__satz { + margin: 7px 0 0; max-width: 66ch; + font-size: .82rem; line-height: 1.6; color: var(--text-leise); +} +.t-uebergang__hinweis { + margin: 8px 0 0; max-width: 66ch; + font-size: .8rem; line-height: 1.55; color: #d8a76a; +} + +.t-uebergang__tag { margin: 6px 0 0; padding-left: 18px; } +.t-uebergang__punkt { + margin: 3px 0; max-width: 66ch; + font-size: .81rem; line-height: 1.55; color: var(--text-leise); +} +.t-uebergang__punkt strong { color: var(--text); font-weight: 620; } + +/* ---- Das Fragefeld ---------------------------------------------------- + Zwei Felder untereinander, nicht nebeneinander: Auf einem Handy + werden aus zwei Spalten zwei zu schmale Spalten, und ein Datumsfeld, + das nicht vollständig hineinpasst, öffnet den Gerätekalender an der + falschen Stelle. */ +.t-uebergang--frage { border-left-color: color-mix(in srgb, #8ec9ff 50%, transparent); } +.t-uebergang__feld { + display: grid; gap: 4px; + margin: 10px 0 0; max-width: 34ch; +} +.t-uebergang__wahl { + font: inherit; font-size: .88rem; + padding: 8px 10px; + color: var(--text); + background: var(--tinte); + border: 1px solid var(--rand-hell); + border-radius: var(--radius); + /* Der eigene Kalenderknopf des Browsers ist auf Dunkel hell gezeichnet. + `color-scheme` sagt ihm, dass er dunkel zeichnen soll — sonst sitzt + ein weißes Kästchen im dunklen Feld. */ + color-scheme: dark; +} +.t-uebergang__wahl:focus-visible { + outline: 2px solid var(--akzent); outline-offset: 1px; +} +.t-uebergang__knoepfe { + display: flex; flex-wrap: wrap; gap: 9px; margin: 13px 0 0; +} +.t-uebergang__aendern { margin: 11px 0 0; } diff --git a/workspace/assets/js/talente.js b/workspace/assets/js/talente.js index 508c39fd..eee17288 100644 --- a/workspace/assets/js/talente.js +++ b/workspace/assets/js/talente.js @@ -36,6 +36,24 @@ return a; } + /* TAGESDATUM IN ORTSZEIT. Nicht toISOString().slice(0,10) -- das + rechnet in UTC und liefert in Deutschland zwischen Mitternacht und + zwei Uhr morgens den Vortag. Ein Termin, der beim Eintragen einen + Tag zurueckspringt, ist genau die Sorte Fehler, die man erst + bemerkt, wenn jemand umsonst gewartet hat. */ + const tag = (d) => { + const z = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; + }; + const heute = () => tag(new Date()); + const inTagen = (n) => { const d = new Date(); d.setDate(d.getDate() + n); return tag(d); }; + const tageBis = (s) => { + if (!s) return null; + const a = new Date(heute() + 'T12:00:00'); + const b = new Date(s + 'T12:00:00'); + return Number.isFinite(b.getTime()) ? Math.round((b - a) / 86400000) : null; + }; + let lage = null; let darfAnlegen = false; // darf ich hier einen Modi-Zugang anlegen? const offen = new Set(); // welche Karten aufgeklappt sind @@ -100,6 +118,21 @@ b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; + /* ZUERST DIE ZWEI FELDER, DANN DIE STUFE. Andersherum stünde + die Karte auf „Probe", während der Buddy noch offen ist — + und genau dieser Zustand war bisher der Normalfall. */ + let uebergang = {}; + if (k.naechste === 'probe') { + const antwort = await uebergangFragen(k, a, { + titel: 'Probe: zwei Dinge, dann läuft es', + satz: 'Wer ihn einarbeitet und wann die erste Schicht ist. Beides jetzt, ' + + 'nicht „melden wir uns“ — zwischen Zusage und erster Schicht springen ' + + 'die meisten ab.', + knopf: 'Auf Probe nehmen', + }); + if (!antwort) { b.disabled = false; return; } + uebergang = antwort; + } let zugang = null; if (zugangNoetig) { zugang = await zugangAnlegen(k); @@ -108,7 +141,7 @@ const r = await hole(`/workspace/api/talente/${k.id}/stufe`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ stufe: k.naechste }), + body: JSON.stringify({ stufe: k.naechste, ...uebergang }), }); b.disabled = false; if (!r.ok) { @@ -119,7 +152,7 @@ melde(zugang ? `Der Zugang für ${zugang.name} steht – der Code steht oben. Nur die Karte ` + 'blieb auf „Probe“. Bitte den Schritt gleich noch einmal setzen.' - : 'Ging nicht.'); + : absage(await r.json().catch(() => ({})))); return; } offen.add(k.id); @@ -151,6 +184,62 @@ } a.append(schritt); + /* ---- Buddy und erste Schicht --------------------------------------- + Sie stehen direkt unter dem nächsten Schritt und über allem + anderen: Während einer Probe sind sie die Karte. */ + if (k.uebergang) { + const u = el('div', 't-uebergang'); + if (k.uebergang.buddy_weg) u.dataset.weg = 'ja'; + + const zeile = el('p', 't-uebergang__zeile'); + zeile.append(el('span', 't-uebergang__was', 'Arbeitet ein')); + zeile.append(el('span', 't-uebergang__wer', + k.uebergang.buddy_name || 'niemand mehr – bitte neu bestimmen')); + u.append(zeile); + + if (k.uebergang.satz) u.append(el('p', 't-uebergang__satz', k.uebergang.satz)); + + /* WAS JEMAND AM ERSTEN TAG BEKOMMT. Drei Dinge, keine Haken. + Sie stehen hier, damit der Buddy sie liest — eine Liste zum + Abhaken wäre die zweite neben der im Wissensbereich. */ + if (k.stufe === 'probe' && (lage.erster_tag || []).length) { + const liste = el('ul', 't-uebergang__tag'); + for (const p of lage.erster_tag) { + const li = el('li', 't-uebergang__punkt'); + li.append(el('strong', null, p.titel)); + li.append(document.createTextNode(' — ' + p.text)); + liste.append(li); + } + u.append(el('p', 't-uebergang__was', 'Am ersten Tag braucht er drei Dinge')); + u.append(liste); + } + + if (k.stufe === 'probe') { + const aendern = el('button', 'schritt t-uebergang__aendern', 'Buddy oder Termin ändern'); + aendern.type = 'button'; + aendern.addEventListener('click', async () => { + const antwort = await uebergangFragen(k, a, { + titel: 'Buddy oder Termin ändern', + satz: 'Ein Buddy hört auf, ein Termin verschiebt sich. Die Standzeit der ' + + 'Karte bleibt dabei stehen — sie zählt, seit wann die Probe läuft.', + knopf: 'Übernehmen', + buddy: k.uebergang.buddy_id, + datum: k.uebergang.erste_schicht, + }); + if (!antwort) return; + const r = await hole(`/workspace/api/talente/${k.id}/uebergang`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(antwort), + }); + if (!r.ok) { melde(absage(await r.json().catch(() => ({})))); return; } + await laden(); + }); + u.append(aendern); + } + a.append(u); + } + /* ---- Merkmale, aufklappbar ------------------------------------------ Zugeklappt steht dort eine Zahl, aufgeklappt die 21 Merkmale. Immer offen wären es bei zehn Kandidaten 270 Kästchen auf einer @@ -325,6 +414,109 @@ feld.focus(); } + /* ===================================================================== + DER ÜBERGANG — ein Name und ein Datum (15.09.2026) + + Filipe: „ich will die noch viel besser […] so einfach wie möglich + für jeden." + + Die Auswahl war der gut gebaute Teil dieser Seite. Verloren gehen + Leute dahinter: zwischen dem „ja" und der ersten echten Schicht. + Wer zusagt und danach zwei Wochen nichts hört, hat innerlich schon + abgesagt — und auf der Karte steht weiter „angesprochen". + + KEIN prompt() FÜR EIN DATUM. Der Zugang nebenan benutzt eins für + einen Namen, und dort geht das. Ein Datum, das jemand in ein + Textfeld tippt, kommt als „nächsten Samstag" zurück. Hier steht + deshalb eine Auswahl mit Namen und ein echter Datumsknopf — auf + dem Handy der Kalender des Geräts. + ===================================================================== */ + function uebergangFragen(k, karteEl, art) { + return new Promise((fertig) => { + document.querySelector('.t-uebergang--frage')?.remove(); + const kasten = el('form', 't-uebergang t-uebergang--frage'); + kasten.append(el('h3', 't-uebergang__titel', art.titel)); + kasten.append(el('p', 't-uebergang__satz', art.satz)); + + const w1 = el('label', 't-uebergang__feld'); + w1.append(el('span', 't-uebergang__was', 'Wer arbeitet ein')); + const wahl = el('select', 't-uebergang__wahl'); + const leer = el('option', null, 'bitte wählen'); + leer.value = ''; + wahl.append(leer); + for (const p of (lage.buddys || [])) { + const o = el('option', null, p.name); + o.value = String(p.id); + if (art.buddy && art.buddy === p.id) o.selected = true; + wahl.append(o); + } + w1.append(wahl); + kasten.append(w1); + + const w2 = el('label', 't-uebergang__feld'); + w2.append(el('span', 't-uebergang__was', 'Erste Schicht')); + const datum = el('input', 't-uebergang__wahl'); + datum.type = 'date'; + datum.min = heute(); + /* DREI TAGE ALS VORSCHLAG, nicht heute und nicht leer. Heute wäre + eine Behauptung, leer wäre eine Aufgabe. Drei Tage sind nah + genug, dass es nicht verläuft, und weit genug, dass man sich + abstimmen kann. */ + datum.value = art.datum || inTagen(3); + w2.append(datum); + kasten.append(w2); + + const hinweis = el('p', 't-uebergang__hinweis'); + kasten.append(hinweis); + const nachsehen = () => { + const bis = tageBis(datum.value); + const weit = lage.schicht_weit_tage || 14; + hinweis.textContent = bis !== null && bis > weit + ? `Das sind ${bis} Tage hin. Bis dahin hört man nichts voneinander – ` + + 'wenn es nicht anders geht, meldet euch zwischendurch einmal.' + : ''; + hinweis.hidden = !hinweis.textContent; + }; + datum.addEventListener('change', nachsehen); + nachsehen(); + + const reihe = el('div', 't-uebergang__knoepfe'); + const ja = el('button', 'schritt t-uebergang__ok', art.knopf); + ja.type = 'submit'; + reihe.append(ja); + const ab = el('button', 'schritt', 'Abbrechen'); + ab.type = 'button'; + ab.addEventListener('click', () => { kasten.remove(); fertig(null); }); + reihe.append(ab); + kasten.append(reihe); + + kasten.addEventListener('submit', (e) => { + e.preventDefault(); + if (!wahl.value) { melde('Ohne einen Namen geht die Probe nicht los.'); wahl.focus(); return; } + if (!datum.value) { melde('Und ohne Datum auch nicht.'); datum.focus(); return; } + melde(''); + kasten.remove(); + fertig({ buddy_id: Number(wahl.value), erste_schicht: datum.value }); + }); + + karteEl.append(kasten); + wahl.focus(); + }); + } + + /* Was der Server absagt, in einem Satz, der sagt WAS fehlt. + „Ging nicht" wäre hier die unbrauchbarste Auskunft überhaupt — die + beiden Felder standen ja gerade noch auf dem Bildschirm. */ + const UEBERGANG_ABSAGE = { + buddy: 'Ohne einen Namen geht die Probe nicht los.', + buddy_unbekannt: 'Diese Person kann gerade niemanden einarbeiten.', + erste_schicht: 'Es fehlt das Datum der ersten Schicht.', + datum_unlesbar: 'Das Datum konnte ich nicht lesen.', + datum_zu_alt: 'Das Datum liegt zu weit zurück.', + datum_zu_weit: 'Das Datum liegt zu weit in der Zukunft – das ist kein Termin mehr.', + }; + const absage = (antwort) => UEBERGANG_ABSAGE[(antwort?.fehlt || [])[0]] || 'Ging nicht.'; + function merkmal(k, m, istWarnung) { const gesetzt = (istWarnung ? k.warnzeichen : k.merkmale).includes(m.schluessel); const b = el('button', 't-merkmal'); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 2ffee13d..67a9eb9e 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -282,10 +282,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 56d346d0..029b0f97 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index be4461f0..dd9a41ab 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -84,9 +84,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index cff58395..bcc763fa 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 2bc11208..cfda0ea4 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 8e664273..8f2fd619 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 96952181..72b5768f 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 7c602616..013ea3d8 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 457db629..b6af8388 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609151345", + "src": "/assets/img/app-symbole/crew-192.png?v=202609151355", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609151345", + "src": "/assets/img/app-symbole/crew-512.png?v=202609151355", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151345", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151355", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151345", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151355", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 21d61812..911c693d 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 8bd403cb..996af6c4 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index f31b7a4d..011ca9cb 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index d0a1c658..3f4c1fe7 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 38241e85..06b902e3 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index b3412429..f97120fa 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index c339c664..f0356e7f 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 91bfd153..5ccc2eaf 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - + + + + diff --git a/workspace/report.html b/workspace/report.html index 76bf5e51..b1899254 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 3fe9bf1a..30600472 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index e95b4c05..0382e001 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index ec760db3..362d8a5e 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index f353daad..b7f4a1ee 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -146,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index c909717a..4f41331b 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 759c7d36..ac96465b 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index ac7b6fad..d9eeeda6 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 026dda7a..1fd57d43 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index b0398965..2c74c3bc 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -210,10 +210,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index a7d9fe45..8e7393b9 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index b9cb49dd..0aa1c3b5 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +