diff --git a/server/pruef-eskalation.mjs b/server/pruef-eskalation.mjs new file mode 100644 index 00000000..48b564a5 --- /dev/null +++ b/server/pruef-eskalation.mjs @@ -0,0 +1,307 @@ +/* ===================================================================== + Wie lange geht das schon so? (15.09.2026) + + Stufe 9: „Eskalationsstufe sichtbar“. + + --------------------------------------------------------------------- + GEPRÜFT WIRD AN DEN GRENZEN, NICHT IN DER MITTE + + Eine Stufenfunktion ist überall dort richtig oder falsch, wo sie + umspringt — dazwischen ist sie langweilig. Jede Schwelle wird deshalb + dreimal gemessen: einen Tag davor, genau darauf, einen Tag danach. + + Die wichtigste Grenze ist die erste: **am Tag der Frist ist nichts + überfällig.** Wer bis zum 15. Zeit hat, ist am 15. pünktlich. Eine + Eskalation, die einen Tag zu früh anschlägt, verliert genau das + Vertrauen, das sie braucht. + + --------------------------------------------------------------------- + UND ZWEI DINGE, DIE NICHT ESKALIEREN DÜRFEN + + Erledigtes und Abgebrochenes — es liegt niemandem mehr im Weg. + Eine Aufgabe mit Frist in der Zukunft — sie ist eingeplant, nicht + still. + + Aufrufen mit: node server/pruef-eskalation.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } 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(4417, "pruef-eskalation"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-eskal-")); +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(180_000, "pruef-eskalation"); +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 HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const { eskalation, STUFEN, STILL_TAGE } = await import("./workspace-eskalation.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +/* EIN FESTER ZEITPUNKT, keine Wanderuhr. Eine Pruefung, die "heute" + als Annahme benutzt, misst irgendwann das Gegenteil -- der Kalender + holt sie ein. Hier wird die Zeit uebergeben, also ist sie Eingabe. */ +const NUN = Date.parse("2026-09-15T12:00:00+02:00"); +const tagVor = (n) => new Date(NUN - n * 86400000).toISOString().slice(0, 10); + +let idDogi; +{ + const d = oeffne(); + const schluessel = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync("CODE-DOGI-0001", 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 ('Filipe','admin',?,?,?,?,1,?)") + .run(hash, salt, 32768, createHmac("sha256", schluessel).update("CODE-DOGI-0001").digest("hex"), jetzt); + idDogi = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.close(); +} + +function ruf(pfad, 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: HAUS, ...(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 {}; } }; +const anm = await ruf("/workspace/api/anmelden", "POST", {}, + { rolle: "admin", code: "CODE-DOGI-0001" }); +const kDogi = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +ok(!!kDogi, "DogFather ist angemeldet"); + +/** Kurzform: eine gedachte Aufgabe einstufen. */ +const stufeVon = (a) => eskalation(a, NUN)?.stufe ?? null; +const tageVon = (a) => eskalation(a, NUN)?.tage ?? null; + +/* ======================================================================= + 1. DIE ERSTE GRENZE: AM TAG DER FRIST IST NICHTS ÜBERFÄLLIG + ======================================================================= */ +melde(""); +melde("=== 1. Der Tag der Frist ==="); +{ + const offen = (frist) => ({ status: "offen", frist }); + + ok(stufeVon(offen(tagVor(-1))) === null, "eine Frist von morgen eskaliert nicht"); + ok(stufeVon(offen(tagVor(0))) === null, + "am Tag der Frist selbst auch nicht — wer bis zum 15. Zeit hat, ist am 15. pünktlich"); + ok(stufeVon(offen(tagVor(1))) === "faellig", + `einen Tag danach schon (${stufeVon(offen(tagVor(1)))})`); + ok(tageVon(offen(tagVor(1))) === 1, `und zwar seit ${tageVon(offen(tagVor(1)))} Tag`); +} + +/* ======================================================================= + 2. JEDE SCHWELLE DREIMAL: DAVOR, DARAUF, DANACH + ======================================================================= */ +melde(""); +melde("=== 2. An den Grenzen ==="); +{ + const bei = (tage) => eskalation({ status: "offen", frist: tagVor(tage) }, NUN); + ok(STUFEN.length === 3, `${STUFEN.length} Stufen im Katalog`); + + for (const s of STUFEN) { + const davor = bei(s.ab - 1); + const drauf = bei(s.ab); + const danach = bei(s.ab + 1); + ok(drauf?.stufe === s.schluessel, + `bei ${s.ab} Tagen gilt „${s.schluessel}“ (${drauf?.stufe})`); + ok(danach?.rang >= drauf.rang, + `einen Tag danach nicht milder (${danach?.stufe})`); + /* Die Stufe DAVOR muss eine andere sein -- sonst waere die Schwelle + wirkungslos, und die Zeile darueber trotzdem gruen. */ + ok(davor === null || davor.stufe !== s.schluessel, + `und einen Tag davor noch nicht (${davor?.stufe ?? "gar nichts"})`); + } + + /* Die Zahl im Wort muss zur Zahl im Feld passen -- sonst liest jemand + "liegt seit 5 Tagen" an einer Karte, die seit 12 liegt. */ + const zwoelf = bei(12); + ok(zwoelf.wort.includes(String(zwoelf.tage)), + `das Wort nennt dieselbe Zahl wie das Feld (${zwoelf.wort} / ${zwoelf.tage})`); + ok(zwoelf.stufe === "haengt", `und zwölf Tage sind „${zwoelf.stufe}“`); +} + +/* ======================================================================= + 3. WAS NICHT ESKALIEREN DARF + ======================================================================= */ +melde(""); +melde("=== 3. Was ruhig bleibt ==="); +{ + ok(stufeVon({ status: "erledigt", frist: tagVor(60) }) === null, + "eine erledigte Aufgabe eskaliert nicht, auch nach 60 Tagen nicht"); + ok(stufeVon({ status: "abgebrochen", frist: tagVor(60) }) === null, + "eine abgebrochene ebenso wenig"); + ok(stufeVon({ status: "arbeit", frist: tagVor(-30) }) === null, + "und eine mit Frist in der Zukunft ist eingeplant, nicht still"); + + /* GEGENPROBE -- ohne sie beweisen die drei Zeilen nichts: Dieselbe + Frist bei offenem Status eskaliert sehr wohl. */ + ok(stufeVon({ status: "offen", frist: tagVor(60) }) === "haengt", + "dieselbe alte Frist bei einer offenen Aufgabe dagegen schon"); +} + +/* ======================================================================= + 4. OHNE FRIST: EINE BEOBACHTUNG, KEIN GEBROCHENES VERSPRECHEN + ======================================================================= */ +melde(""); +melde("=== 4. Ohne Frist ==="); +{ + const still = (tage) => ({ + status: "offen", frist: null, + geaendert: new Date(NUN - tage * 86400000).toISOString(), + }); + + ok(stufeVon(still(STILL_TAGE - 1)) === null, + `${STILL_TAGE - 1} Tage ohne Bewegung sind noch nichts`); + ok(stufeVon(still(STILL_TAGE)) === "still", + `ab ${STILL_TAGE} Tagen steht es da (${stufeVon(still(STILL_TAGE))})`); + ok(eskalation(still(20), NUN)?.grund === "keine_bewegung", + "und der Grund ist ein anderer als bei einer Frist"); + ok(/unberührt/.test(eskalation(still(20), NUN)?.wort || ""), + `das Wort wirft niemandem etwas vor: „${eskalation(still(20), NUN)?.wort}“`); + + /* Ohne `geaendert` zaehlt das Anlegedatum -- sonst waere eine nie + angefasste Aufgabe fuer immer unsichtbar. */ + const nie = { status: "offen", frist: null, geaendert: null, + erstellt: new Date(NUN - 30 * 86400000).toISOString() }; + ok(stufeVon(nie) === "still", "eine nie angefasste Aufgabe zählt ab dem Anlegen"); + + /* GEGENPROBE: Eine Aufgabe MIT Frist wird nicht zusaetzlich als + "still" gemeldet -- zwei Marken an einer Karte waeren Laerm. */ + const beides = { status: "offen", frist: tagVor(-5), + geaendert: new Date(NUN - 40 * 86400000).toISOString() }; + ok(stufeVon(beides) === null, + "eine eingeplante Aufgabe wird nicht zusätzlich als still gemeldet"); +} + +/* ======================================================================= + 5. DIE STUFE KOMMT MIT DER LISTE — GERECHNET, NICHT IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 5. Über die Leitung ==="); +{ + const d = oeffne(); + const rein = d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, frist, erstellt, erstellt_von) + VALUES (?,?,'mittel',?,?,?)`); + const heute = new Date().toISOString().slice(0, 10); + const vor = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + rein.run("Seit gestern faellig", "offen", vor(1), jetzt, idDogi); + rein.run("Liegt seit fuenf Tagen", "offen", vor(5), jetzt, idDogi); + rein.run("Haengt seit zwanzig", "offen", vor(20), jetzt, idDogi); + rein.run("Erst naechste Woche", "offen", new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), jetzt, idDogi); + rein.run("Heute faellig", "offen", heute, jetzt, idDogi); + d.close(); + + const liste = json(await ruf("/workspace/api/aufgaben", "GET", { cookie: kDogi })).aufgaben || []; + ok(liste.length >= 5, `${liste.length} Aufgaben in der Liste`); + const nach = (t) => liste.find((a) => a.titel === t); + + ok(nach("Seit gestern faellig")?.eskalation?.stufe === "faellig", + `„seit gestern“ → ${nach("Seit gestern faellig")?.eskalation?.stufe}`); + ok(nach("Liegt seit fuenf Tagen")?.eskalation?.stufe === "liegt", + `„seit fünf Tagen“ → ${nach("Liegt seit fuenf Tagen")?.eskalation?.stufe}`); + ok(nach("Haengt seit zwanzig")?.eskalation?.stufe === "haengt", + `„seit zwanzig“ → ${nach("Haengt seit zwanzig")?.eskalation?.stufe}`); + ok(nach("Erst naechste Woche")?.eskalation === null, + "„erst nächste Woche“ trägt keine Stufe"); + ok(nach("Heute faellig")?.eskalation === null, + "und „heute fällig“ ebenfalls nicht — heute ist nicht zu spät"); + + /* Jede Stufe bringt ihr WORT mit. Rechnete der Browser es selbst, + waere das die zweite Wahrheit. */ + ok(/\d/.test(nach("Haengt seit zwanzig")?.eskalation?.wort || ""), + `das Wort kommt fertig mit: „${nach("Haengt seit zwanzig")?.eskalation?.wort}“`); +} + +/* ======================================================================= + 6. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 6. 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: 900 } }); + 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/`, { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/aufgaben.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + const stand = await seite.evaluate(() => { + const m = [...document.querySelectorAll(".karte__eskal")]; + return { + anzahl: m.length, + stufen: [...new Set(m.map((x) => x.dataset.stufe))].sort(), + texte: m.map((x) => x.textContent.trim()), + /* Steht das Wort "ueberfaellig" noch zweimal an derselben Karte? */ + doppelt: [...document.querySelectorAll(".karte")] + .filter((k) => (k.innerText.match(/überfällig/gi) || []).length > 1).length, + }; + }); + ok(stand.anzahl >= 3, `${stand.anzahl} Karten tragen eine Stufe`); + ok(stand.stufen.length >= 3, + `und es sind verschiedene: ${stand.stufen.join(", ")}`); + ok(stand.texte.some((t) => /\d+ Tagen/.test(t)), + `die Zahl steht im Text: ${stand.texte.join(" | ").slice(0, 90)}`); + /* DIE FARBE IST NICHT DIE AUSKUNFT. Wer Farben schlecht sieht, muss + dasselbe lesen koennen -- also steht in jedem Kaestchen ein Wort. */ + ok(stand.texte.every((t) => t.length > 3), "jede Stufe steht als Wort da, nicht nur als Farbe"); + ok(stand.doppelt === 0, + `und „überfällig“ steht an keiner Karte zweimal (${stand.doppelt})`); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + await browser.close(); +} + +/* ------------------------------------------------------------------- */ +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-aufgaben.js b/server/workspace-aufgaben.js index 75d96278..50ae08bf 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -15,6 +15,7 @@ import { MODI_KATEGORIEN, kategorienFuer, kategoriePersonen, ohneTeamDogi, ohneAgentur, siehtModis, TEAM_DOGI_ROLLEN, } from "./workspace.js"; +import { eskalationSetzen } from "./workspace-eskalation.js"; export const aufgabenRouter = express.Router(); @@ -267,6 +268,12 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { CASE WHEN a.frist IS NULL THEN 1 ELSE 0 END, a.frist, CASE a.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, a.id DESC`).all(...werte, heuteLokal()); + /* WIE LANGE GEHT DAS SCHON SO? Am Server gerechnet, nicht im + Browser: Startseite, Brett und Uebersicht lesen dieselbe Liste, + und drei Stellen, die dieselbe Frage selbst beantworten, geben + irgendwann drei Antworten. */ + eskalationSetzen(reihen); + res.json({ aufgaben: reihen, /* Die Kategorien kommen mit der Liste, nicht ueber einen eigenen diff --git a/server/workspace-eskalation.js b/server/workspace-eskalation.js new file mode 100644 index 00000000..a17a630e --- /dev/null +++ b/server/workspace-eskalation.js @@ -0,0 +1,151 @@ +/* ===================================================================== + Wie lange geht das schon so? (15.09.2026) + + Stufe 9 aus dem Plan: „Eskalationsstufe sichtbar". + + --------------------------------------------------------------------- + DER MANGEL, GEMESSEN + + Überfällig war bisher ein Schalter: an oder aus. Eine Aufgabe, deren + Frist gestern lief, sah genauso aus wie eine, die seit drei Wochen + liegt — dasselbe Wort, dieselbe Farbe, derselbe Platz. + + Damit verliert das Wort seine Bedeutung. Wenn auf einem Brett zwanzig + Karten „überfällig" stehen, sagt keine davon mehr etwas; man + überliest sie alle, auch die, die seit einem Monat blockiert. + + --------------------------------------------------------------------- + ZWEI VERSCHIEDENE FRAGEN — UND SIE DÜRFEN NICHT VERMISCHT WERDEN + + FRIST ÜBERSCHRITTEN Es war etwas versprochen, und der Termin ist + vorbei. Eine Tatsache. + + NICHTS PASSIERT Es gibt keine Frist, aber seit Wochen hat + niemand die Karte angefasst. Eine Beobachtung. + + Die erste ist härter als die zweite, und sie steht deshalb vorn. Eine + Aufgabe ohne Frist ist nicht „zu spät" — sie ist nur still. Beides in + einen Topf zu werfen hieße, jemandem einen gebrochenen Termin + vorzuwerfen, den er nie zugesagt hat. + + --------------------------------------------------------------------- + DAS IST KEINE MAHNUNG + + Dieselbe Haltung wie bei der Standzeit im Talente-Trichter und bei + „Was auf dich wartet": Die Zahl ist eine Auskunft über UNS, nicht ein + Vorwurf an eine Person. Deshalb steht hier auch nirgends ein Name, + und deshalb heißt die höchste Stufe „hängt" und nicht „versäumt". + + --------------------------------------------------------------------- + DIE SCHWELLEN SIND GESETZT, NICHT GEMESSEN + + Ehrlich benannt: 3 und 8 Tage sind ein begründeter Anfang, kein + Messergebnis. Die Überlegung dahinter: + + unter 3 Tagen Ein Wochenende reicht dafür. Wer Freitag eine + Frist reißt und Montag liefert, hat nichts + versäumt — nur den Kalender nicht gelesen. + ab 3 Tagen Länger als ein Wochenende. Jetzt ist es keine + Verzögerung mehr, sondern ein Zustand. + ab 8 Tagen Über eine Woche. Wer so lange nicht dazu kommt, + kommt ohne Hilfe auch nächste Woche nicht dazu. + + Wenn sich zeigt, dass die Stufen zu früh oder zu spät greifen, wird + hier eine Zahl geändert — an einer Stelle, und die Oberfläche zieht + mit, weil sie das Wort vom Server bekommt. + ===================================================================== */ + +/** Die Stufen, von der mildesten zur härtesten. + * + * `ab` ist die Anzahl Tage, ab der die Stufe gilt. Die Liste wird von + * hinten durchsucht — die härteste zutreffende gewinnt. */ +export const STUFEN = [ + { schluessel: "faellig", ab: 1, rang: 1 }, + { schluessel: "liegt", ab: 3, rang: 2 }, + { schluessel: "haengt", ab: 8, rang: 3 }, +]; + +/** Ohne Frist: ab wie vielen Tagen ohne Bewegung es auffällt. + * + * 14 Tage — zwei Wochen. Kürzer wäre bei einer Aufgabe ohne Termin + * Panikmache: Nicht alles, was daliegt, ist liegengeblieben. */ +export const STILL_TAGE = 14; + +const TAG = 86400000; + +/** Ganze Tage zwischen zwei Datumsangaben (a später als b). */ +function tageSeit(zeitpunkt, jetzt) { + const t = Date.parse(zeitpunkt); + if (!Number.isFinite(t)) return null; + return Math.floor((jetzt - t) / TAG); +} + +/** Wie lange geht das mit dieser Aufgabe schon so? + * + * @param {object} a eine Zeile aus `aufgaben` + * @param {number} [jetzt] Zeitpunkt in ms (für die Prüfung setzbar) + * @returns {{stufe: string, tage: number, grund: string, wort: string}|null} + * null, wenn nichts anliegt + */ +export function eskalation(a, jetzt = Date.now()) { + if (!a) return null; + /* Erledigtes und Abgebrochenes eskaliert nicht. Eine Karte, die fertig + ist, war vielleicht zu spät fertig -- aber sie liegt niemandem mehr + im Weg, und eine Warnung daran wäre nur noch ein Vorwurf. */ + if (a.status === "erledigt" || a.status === "abgebrochen") return null; + + /* ---- 1. Die Frist. Eine Tatsache, und sie steht vorn. ---- */ + if (a.frist) { + /* Auf den ANFANG des Tages nach der Frist gerechnet: Wer bis zum + 15. Zeit hat, ist am 15. nicht zu spät. Die Grenze ist Mitternacht + danach, nicht der Moment des Nachsehens. */ + const ende = Date.parse(a.frist + "T23:59:59"); + if (Number.isFinite(ende) && jetzt > ende) { + const tage = Math.max(1, Math.floor((jetzt - ende) / TAG) + 1); + const treffer = [...STUFEN].reverse().find((s) => tage >= s.ab); + if (treffer) { + return { + stufe: treffer.schluessel, + rang: treffer.rang, + tage, + grund: "frist", + wort: treffer.schluessel === "faellig" + ? "überfällig" + : (treffer.schluessel === "liegt" + ? `liegt seit ${tage} Tagen` + : `hängt seit ${tage} Tagen`), + }; + } + } + /* MIT FRIST UND NOCH NICHT ÜBERSCHRITTEN: Hier endet es. Eine Karte, + deren Termin erst nächste Woche ist, ist nicht „still" -- sie ist + eingeplant. */ + return null; + } + + /* ---- 2. Ohne Frist: seit wann hat niemand sie angefasst? ---- */ + const zuletzt = a.geaendert || a.erstellt; + const still = zuletzt ? tageSeit(zuletzt, jetzt) : null; + if (still !== null && still >= STILL_TAGE) { + return { + stufe: "still", + rang: 1, + tage: still, + grund: "keine_bewegung", + wort: `seit ${still} Tagen unberührt`, + }; + } + return null; +} + +/** Setzt `eskalation` an jede Zeile einer Liste. + * + * AM SERVER UND NICHT IM BROWSER: Startseite, Aufgabenbrett und + * Übersicht lesen dieselbe Liste. Drei Stellen, die dieselbe Frage + * selbst beantworten, geben irgendwann drei Antworten -- genau der + * Fehler, der im Projekt schon bei der Rollenreihenfolge und beim Wort + * „Agentur" passiert ist. */ +export function eskalationSetzen(liste, jetzt = Date.now()) { + for (const a of liste || []) a.eskalation = eskalation(a, jetzt); + return liste; +} diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 62514ca8..cdd3e288 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=202609151311", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151321", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609151311", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151321", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151311", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151321", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151311", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151321", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 01f870db..fe067f0c 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -1038,3 +1038,46 @@ } .anhaenge__zufuegen:hover .anhaenge__knopf { color: var(--text); border-color: var(--rand-hell); } .anhaenge__hinweis { margin: 6px 0 0; font-size: .78rem; color: var(--text-still); } + +/* ===================================================================== + WIE LANGE GEHT DAS SCHON SO? (15.09.2026) + + Drei Stufen, drei Toene -- aber die Farbe ist NICHT die Auskunft: Im + Text steht "liegt seit 5 Tagen", nicht nur ein roterer Punkt. Wer + Farben schlecht sieht, liest dasselbe. + + Und es bleibt gedaempft. Eine Karte, die schreit, wird weggeklickt; + eine, die ruhig sagt "seit 12 Tagen", wird gelesen. + ===================================================================== */ + +.karte__eskal { + font-size: .76rem; font-weight: 700; + padding: 2px 9px; border-radius: 999px; + letter-spacing: .01em; + color: var(--text-leise); + border: 1px solid var(--rand-hell); +} +/* faellig: es ist gerade passiert. Ein Hinweis, keine Warnung. */ +.karte__eskal[data-stufe="faellig"] { + color: #d8a76a; + border-color: color-mix(in srgb, #cc9451 40%, transparent); +} +/* liegt: laenger als ein Wochenende. Jetzt ist es ein Zustand. */ +.karte__eskal[data-stufe="liegt"] { + color: #e0955f; + border-color: color-mix(in srgb, #cc7a3f 50%, transparent); + background: color-mix(in srgb, #cc7a3f 10%, transparent); +} +/* haengt: ueber eine Woche. Ohne Hilfe wird das auch naechste Woche + nichts. */ +.karte__eskal[data-stufe="haengt"] { + color: #e8886f; + border-color: color-mix(in srgb, #c25c45 55%, transparent); + background: color-mix(in srgb, #c25c45 14%, transparent); +} +/* still: keine Frist gerissen, nur lange nichts passiert. Bewusst + farblos -- das ist eine Beobachtung, kein gebrochenes Versprechen. */ +.karte__eskal[data-stufe="still"] { + color: var(--text-still); + border-style: dashed; +} diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index b68b1443..82e817ea 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -194,10 +194,32 @@ if (a.prioritaet === 'hoch' && a.status !== 'erledigt') { zeile.append(el('span', 'karte__wichtig', 'wichtig')); } + /* ---- WIE LANGE GEHT DAS SCHON SO? (15.09.2026, Stufe 9) -------- + + "Ueberfaellig" war ein Schalter: an oder aus. Eine Karte, deren + Frist gestern lief, sah aus wie eine, die seit drei Wochen liegt. + Stehen auf einem Brett zwanzig Karten "ueberfaellig", sagt keine + davon mehr etwas -- man ueberliest sie alle, auch die, die seit + einem Monat blockiert. + + DAS WORT KOMMT VOM SERVER. Eine zweite Rechnung hier waere die, + die irgendwann anders antwortet als die Startseite. */ + if (a.eskalation) { + const e = el('span', 'karte__eskal', a.eskalation.wort); + e.dataset.stufe = a.eskalation.stufe; + e.title = a.eskalation.grund === 'frist' + ? 'Die Frist ist vorbei. Die Zahl sagt, seit wann.' + : 'Ohne Frist – seit so vielen Tagen hat niemand die Karte angefasst.'; + zeile.append(e); + } + if (a.frist) { - const faellig = a.status !== 'erledigt' && a.frist < heute; + /* Das Wort "ueberfaellig" steht jetzt oben in der Stufe. Hier + bleibt nur noch das Datum -- zweimal dasselbe nebeneinander + waere Laerm. */ + const faellig = !!a.eskalation && a.eskalation.grund === 'frist'; const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''), - (faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist)); + 'bis ' + datumHuebsch(a.frist)); zeile.append(f); } /* Die Kategorie als Wort in derselben Zeile wie Frist und Person. diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index e891bc86..b427f954 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. --> - - - - - - + + + + + + - +
@@ -267,10 +267,10 @@ - - - - - + + + + +