diff --git a/server/bild-calls.mjs b/server/bild-calls.mjs new file mode 100644 index 00000000..b617216b --- /dev/null +++ b/server/bild-calls.mjs @@ -0,0 +1,102 @@ +/* Macht ein Bild der Call-Liste mit vielen Terminen -- zum Ansehen. + Nachgestellt wird Filipes Lage: eine wöchentliche Serie, die sich + über Monate zieht. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { heuteLokal } from "./helfer-zeit.mjs"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-callbild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4319, "das Bildwerkzeug"); +process.env.PORT = "4319"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4319"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); + +/* Wie bei Filipe: zwei wöchentliche Serien über ein Vierteljahr, dazu + etwas heute und diese Woche. */ +const tag = (versatz) => { + const t = new Date(); + t.setDate(t.getDate() + versatz); + const p = (n) => String(n).padStart(2, "0"); + return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`; +}; +function call(titel, versatz, zeit, beschreibung) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, + teilnehmer_id, beschreibung, erstellt, erstellt_von) + VALUES (?,'call',?,30,?,?,?,?,?)`) + .run(titel, `${tag(versatz)}T${zeit}`, idLuna, idLuna, beschreibung, jetzt, idDogi); +} +call("Wochen-Call mit Luna", 0, "18:00", "Kurzer Stand"); +call("Content-Abnahme", 0, "20:00", "Freigabe der Clips"); +call("Community-Talk", 2, "18:00", "Community-Call"); +call("GrundSchulung", 3, "18:00", "Schulung über GrundRegeln"); +for (let w = 1; w <= 12; w++) { + call("Community-Talk", 7 * w, "18:00", "Community-Call"); + call("GrundSchulung", 7 * w + 1, "18:00", "Schulung über GrundRegeln"); +} +/* Und zwei vergangene ohne Protokoll, damit die erste Gruppe nicht + leer ist. */ +call("Rückblick August", -3, "17:00", "Monatsgespräch"); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 1100 }, deviceScaleFactor: 2 }); +const seite = await ctx.newPage(); +await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); +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: 25000 }); +await seite.goto(BASIS + "/workspace/calls.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(1600); + +const bloecke = await seite.$$eval(".zeitblock", (b) => b.map((x) => ({ + raum: x.dataset.zeitraum, + offen: x.open, + zahl: x.querySelector(".zeitblock__zahl")?.textContent, +}))); +console.log("Blöcke:", JSON.stringify(bloecke)); + +/* Nur den anstehenden Teil ins Bild -- darum ging es. */ +const teil = await seite.$('.gruppe[data-gruppe="anstehend"]'); +if (teil) { + await teil.screenshot({ path: "calls-kategorien.png" }); + console.log("calls-kategorien.png geschrieben"); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(0); diff --git a/server/bild-chat.mjs b/server/bild-chat.mjs new file mode 100644 index 00000000..4df2170d --- /dev/null +++ b/server/bild-chat.mjs @@ -0,0 +1,123 @@ +/* Macht Bilder des Chats -- zum Ansehen, nicht zum Pruefen. + Zwei Browser, die sich wirklich schreiben. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-chatbild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4315, "das Bildwerkzeug"); +process.env.PORT = "4315"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4315"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idCig = anlegen("Cigdem", "manager", "CODE-MANA-0001"); +const idPat = anlegen("Patrick von Hohenstein", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCig, jetzt); +d.close(); + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); +} +const kLuna = await anmelden("creator", "CODE-CREA-0001"); +const kPat = await anmelden("scout", "CODE-SCOU-0001"); + +const rufe = (keks, weg, koerper, methode = "POST") => + fetch(BASIS + weg, { method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper) }); + +/* Ein Gespräch mit echtem Inhalt -- damit das Bild zeigt, wie es sich + wirklich liest, und nicht "Test 1 / Test 2". */ +const { raum_id } = await (await rufe(kLuna, "/workspace/api/chat/direkt", { mit: idPat })).json(); +const verlauf = [ + [kPat, "Hey Luna! Kurze Erinnerung: Der Start-Check ist morgen fällig – schaffst du das noch?"], + [kLuna, "Ja klar 👍 Ich mach ihn heute Abend nach dem Stream fertig."], + [kPat, "Top. Und sag Bescheid, falls du bei den Zielen für Q4 Hilfe brauchst."], + [kLuna, "Eine Frage hätte ich tatsächlich: Zählen die Abo-Einnahmen mit rein oder nur Spenden?"], + [kPat, "Beides. Alles was reinkommt, zählt zum Monatsziel."], + [kLuna, "Perfekt, danke dir!"], +]; +for (const [keks, text] of verlauf) { + await rufe(keks, `/workspace/api/chat/raeume/${raum_id}/nachrichten`, { text }); + await new Promise((r) => setTimeout(r, 60)); +} +/* Eine Gruppe, damit die Liste beides zeigt. */ +await rufe(kPat, "/workspace/api/chat/gruppe", + { name: "Luna · Betreuung", mit: [idLuna, idCig] }); +const gruppen = await (await fetch(BASIS + "/workspace/api/chat/raeume", + { headers: { Cookie: kPat } })).json(); +const gruppeId = gruppen.raeume.find((r) => r.art === "gruppe")?.id; +if (gruppeId) { + await rufe(kPat, `/workspace/api/chat/raeume/${gruppeId}/nachrichten`, + { text: "Cigdem, ich hab Luna beim Start-Check unterstützt – Stand ist gut." }); +} +/* Und ein zweites Gespräch, damit die Liste nicht aus einer Zeile besteht. */ +await rufe(kLuna, "/workspace/api/chat/direkt", { mit: idDogi }); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +for (const [breite, hoehe, name] of [[1400, 900, "computer"], [390, 800, "handy"]]) { + const ctx = await browser.newContext({ + viewport: { width: breite, height: hoehe }, deviceScaleFactor: 2, + }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click('.rolle[data-rolle="creator"]'); + await seite.fill("#code", "CODE-CREA-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(`${BASIS}/workspace/chat.html?raum=${raum_id}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + await seite.screenshot({ path: `chat-${name}.png` }); + console.log(`chat-${name}.png geschrieben`); + + /* Am Handy zusätzlich die Gesprächsliste -- das ist die Ansicht, + mit der man dort ankommt. */ + if (name === "handy") { + await seite.click("#zur-liste"); + await seite.waitForTimeout(500); + await seite.screenshot({ path: "chat-handy-liste.png" }); + console.log("chat-handy-liste.png geschrieben"); + } + await ctx.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(0); diff --git a/server/pruef-call-kategorien.mjs b/server/pruef-call-kategorien.mjs new file mode 100644 index 00000000..cfb14295 --- /dev/null +++ b/server/pruef-call-kategorien.mjs @@ -0,0 +1,257 @@ +/* DIE ANSTEHENDEN CALLS, NACH ZEIT GEORDNET. + + Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27": + *"die liste da soll kategorisiert sein und werden mit einem button + zum auf und zu machen."* + + WAS HIER SCHIEFGEHEN KANN, und warum es geprüft wird: + + Die Einteilung rechnet mit Kalenderwochen -- "Diese Woche" heisst + bis Sonntag, nicht "in sieben Tagen". Wer am Freitag "diese Woche" + liest, erwartet nicht den Mittwoch darauf. Solche Rechnungen sind + genau die, die an einem Sonntag oder über einen Monatswechsel + kippen, und niemand merkt es, weil man sie nur einmal ansieht. + + Deshalb werden die Termine hier RELATIV zum Lauftag gesetzt (heute, + morgen, +40 Tage) und die Zuordnung danach nachgerechnet. Ein Lauf + am Dienstag muss dasselbe ergeben wie einer am Samstag. + + GEGENPROBEN: Bei wenigen Terminen darf es die Einteilung gar nicht + geben -- vier Karten in vier Schubladen wären mehr Arbeit als Nutzen. */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-callkat-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4321, "die Kategorienpruefung"); + +process.env.PORT = "4321"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4321"; + +const PROTOKOLL = join(process.cwd(), "pruef-call-kategorien-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); + +/* ORTSZEIT. Ein Datum aus toISOString() läge nachts einen Tag daneben + -- und ausgerechnet die Kategorie "Heute" wäre dann leer. */ +const tag = (versatz) => { + const t = new Date(); + t.setDate(t.getDate() + versatz); + const p = (n) => String(n).padStart(2, "0"); + return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`; +}; +function call(titel, versatz) { + d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, + teilnehmer_id, erstellt, erstellt_von) VALUES (?,'call',?,30,?,?,?,?)`) + .run(titel, `${tag(versatz)}T18:00`, idLuna, idLuna, jetzt, idDogi); +} + +/* WIE VIELE TAGE bis Sonntag? Dieselbe Rechnung wie in calls.js -- + damit die Prüfung weiss, was sie erwarten darf, statt es zu raten. */ +const heute = new Date(); +const wochentag = (heute.getDay() + 6) % 7; // Mo=0 … So=6 +const bisSonntag = 6 - wochentag; + +/* Zwei heute. Dann einer, der sicher noch in diese Woche fällt (falls + es sie gibt), einer in die nächste, und drei weit weg. */ +call("Heute A", 0); +call("Heute B", 0); +if (bisSonntag >= 1) call("Diese Woche", 1); +call("Nächste Woche", bisSonntag + 2); +for (let i = 0; i < 3; i++) call(`Später ${i}`, bisSonntag + 20 + i); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +async function callsSeite() { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + 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: 25000 }); + await seite.goto(BASIS + "/workspace/calls.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + return { ctx, seite }; +} + +/* ======================================================================= + 1. Es gibt Kategorien, und sie stimmen + ======================================================================= */ +melde("\n=== Die Einteilung ==="); +const { ctx, seite } = await callsSeite(); +{ + const bloecke = await seite.$$eval(".zeitblock", (b) => b.map((x) => ({ + raum: x.dataset.zeitraum, + offen: x.open, + zahl: Number(x.querySelector(".zeitblock__zahl")?.textContent || 0), + karten: x.querySelectorAll(".call").length, + }))); + + /* DIE ANZAHL IN DIE BEDINGUNG: Ohne Blöcke liefe alles Folgende über + eine leere Menge und meldete grün. */ + ok(bloecke.length >= 3, `${bloecke.length} Kategorien (erwartet mindestens 3)`); + + const heuteBlock = bloecke.find((b) => b.raum === "heute"); + ok(heuteBlock?.zahl === 2, `"Heute" enthält ${heuteBlock?.zahl} Termine (erwartet 2)`); + + const spaeter = bloecke.find((b) => b.raum === "spaeter"); + ok(spaeter?.zahl === 3, `"Später" enthält ${spaeter?.zahl} (erwartet 3)`); + + const naechste = bloecke.find((b) => b.raum === "naechste"); + ok(naechste?.zahl === 1, `"Nächste Woche" enthält ${naechste?.zahl} (erwartet 1)`); + + /* Am Sonntag gibt es kein "Diese Woche" mehr -- das ist richtig und + kein Fehler. Deshalb wird die Erwartung aus dem Wochentag + abgeleitet, nicht festgeschrieben. */ + const diese = bloecke.find((b) => b.raum === "woche"); + const erwarteDiese = bisSonntag >= 1; + ok(!!diese === erwarteDiese, + erwarteDiese + ? `"Diese Woche" ist da (${diese?.zahl}) — heute ist noch ${bisSonntag} Tag(e) bis Sonntag` + : `"Diese Woche" fehlt zu Recht — heute ist Sonntag, danach kommt nichts mehr`); + + /* Die Zahl im Kopf muss zu den Karten darin passen. Eine Zahl, die + etwas anderes sagt als der Inhalt, ist schlimmer als keine. */ + const stimmig = bloecke.every((b) => b.zahl === b.karten); + ok(stimmig, stimmig + ? `jede Zahl passt zu den Karten darin (${bloecke.map((b) => `${b.raum}:${b.zahl}`).join(", ")})` + : `Zahl und Inhalt weichen ab: ${JSON.stringify(bloecke)}`); +} + +/* ======================================================================= + 2. Auf und zu + ======================================================================= */ +melde("\n=== Der Knopf zum Auf- und Zumachen ==="); +{ + const offen = await seite.$$eval(".zeitblock", (b) => b.map((x) => x.open)); + ok(offen[0] === true, "der erste Block ist offen"); + ok(offen.slice(1).every((o) => o === false), + `alle weiteren sind zu (${offen.map((o) => (o ? "auf" : "zu")).join(", ")})`); + + /* Zumachen und wieder auf. */ + await seite.click(".zeitblock:first-of-type .zeitblock__kopf"); + await seite.waitForTimeout(250); + const zu = await seite.$eval(".zeitblock:first-of-type", (e) => e.open); + ok(!zu, "ein Klick auf die Überschrift macht ihn zu"); + + await seite.click(".zeitblock:first-of-type .zeitblock__kopf"); + await seite.waitForTimeout(250); + const wiederAuf = await seite.$eval(".zeitblock:first-of-type", (e) => e.open); + ok(wiederAuf, "und wieder auf"); + + /* Ein zugeklappter Block versteckt seine Karten WIRKLICH -- nicht + nur optisch. Sonst fände die Browsersuche sie weiterhin und + springt an eine Stelle, an der nichts zu sehen ist. */ + const letzter = await seite.$$(".zeitblock"); + const sichtbarZu = await letzter[letzter.length - 1].$eval(".call", + (e) => e.getBoundingClientRect().height > 0).catch(() => false); + ok(!sichtbarZu, "die Karten eines zugeklappten Blocks sind wirklich verborgen"); + + /* Mit der Tastatur bedienbar --
/ bringt das mit, + aber nur solange niemand es mit CSS oder JS aushebelt. */ + const perTaste = await seite.evaluate(() => { + const s = document.querySelector(".zeitblock:last-of-type .zeitblock__kopf"); + s.focus(); + const vorher = s.parentElement.open; + s.click(); + return vorher !== s.parentElement.open; + }); + ok(perTaste, "der Kopf ist fokussierbar und schaltet um"); +} + +/* ======================================================================= + 3. Bei wenigen Terminen KEINE Einteilung + ======================================================================= */ +melde("\n=== Wenige Termine brauchen keine Schubladen ==="); +{ + /* Vier Karten in vier Kategorien wären mehr Arbeit als Nutzen -- man + klickt dreimal, um vier Zeilen zu sehen. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + p.prepare("DELETE FROM termine WHERE titel LIKE 'Später%' OR titel = 'Nächste Woche'").run(); + const rest = p.prepare("SELECT COUNT(*) AS n FROM termine").get().n; + p.close(); + ok(rest <= 4, `nur noch ${rest} Termine übrig`); + + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + + const bloecke = (await seite.$$(".zeitblock")).length; + const karten = (await seite.$$('.gruppe[data-gruppe="anstehend"] .call')).length; + ok(bloecke === 0 && karten === rest, + `keine Kategorien mehr (${bloecke}), aber alle ${karten} Karten stehen da`); +} + +/* ======================================================================= + GEGENPROBE + ======================================================================= */ +melde("\n=== Gegenprobe ==="); +{ + /* Erkennt die Messung Kategorien überhaupt? Der Abschnitt oben hat + gerade bewiesen, dass sie VERSCHWINDEN -- ohne diesen Nachweis + bewiese "0 Blöcke" nur, dass der Selektor nie etwas trifft. */ + const p = new DatabaseSync(process.env.WORKSPACE_DB); + for (let i = 0; i < 6; i++) { + p.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, + teilnehmer_id, erstellt, erstellt_von) VALUES (?,'call',?,30,?,?,?,?)`) + .run(`Wieder da ${i}`, `${tag(bisSonntag + 30 + i)}T18:00`, idLuna, idLuna, jetzt, idDogi); + } + p.close(); + + await seite.reload({ waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + const bloecke = (await seite.$$(".zeitblock")).length; + ok(bloecke > 0, + `mit genug Terminen kommen die Kategorien zurück (${bloecke}) — die Messung unterscheidet also`); +} + +await ctx.close(); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +/* Server und Zeitgeber laufen weiter -- siehe pruef-chat-optik. */ +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-chat-optik.mjs b/server/pruef-chat-optik.mjs new file mode 100644 index 00000000..5821dc3f --- /dev/null +++ b/server/pruef-chat-optik.mjs @@ -0,0 +1,333 @@ +/* CHAT — SCHREIBEN ZWEI LEUTE WIRKLICH MITEINANDER? + + pruef-chat.mjs beweist, dass der Server richtig entscheidet, WER mit + wem darf. Diese Prüfung fragt das, was der Wunsch eigentlich meinte: + *"jeder kann dann eine nachricht reinschreiben, die andere person + bekommt eine benachrichtigung und kann dann antworten."* + + Dafür laufen ZWEI Browser gleichzeitig. Luna schreibt, und ohne dass + irgendjemand etwas neu lädt, muss die Nachricht bei Patrick + erscheinen. Das ist der Kern -- alles andere ist Ausstattung. + + WARUM ZWEI ECHTE BROWSER und nicht zwei Anfragen: Die sofortige + Zustellung hängt an einer offenen Verbindung (SSE), an einem + EventSource im Browser und daran, dass die Seite darauf richtig + reagiert. Mit fetch allein prüfte man den Server, nicht den Chat. + + GEMESSEN WIRD AUSSERDEM: + * Die Zahl in der Kopfleiste -- auf einer ANDEREN Seite, denn dort + muss sie ankommen, damit jemand von der Nachricht erfährt. + * Dass die eigene Nachricht sofort dasteht, nicht erst nach der + Antwort des Servers. + * Dass "gelesen" die Zahl wieder wegnimmt. + + GEGENPROBEN durchgehend. */ + +import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-chatoptik-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4317, "die Chat-Optikpruefung"); + +process.env.PORT = "4317"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4317"; + +const PROTOKOLL = join(process.cwd(), "pruef-chat-optik-lauf.txt"); +try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } +const melde = (z) => { + console.log(z); + try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } +}; +let fehler = 0; +const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.close(); + +const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +async function alsRolle(rolle, code, wohin = "chat.html", breite = 1280) { + const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } }); + const seite = await ctx.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 25000 }); + await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + return { ctx, seite }; +} + +/* ======================================================================= + 1. Ein Gespräch anfangen + ======================================================================= */ +melde("\n=== Ein Gespräch anfangen ==="); +const luna = await alsRolle("creator", "CODE-CREA-0001"); +{ + const leer = await luna.seite.isVisible("#liste-leer"); + ok(leer, "am Anfang steht da, dass es noch keine Gespräche gibt"); + + await luna.seite.click("#neu-oeffnen"); + await luna.seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 }); + + const knoepfe = await luna.seite.$$eval(".chat-wahl__knopf", + (b) => b.map((x) => x.textContent.trim())); + /* Luna erreicht ihren Scout und DogFather -- die Regel aus + pruef-chat, hier an der Oberfläche nachgewiesen. */ + ok(knoepfe.length === 2, `${knoepfe.length} Personen zur Auswahl: ${knoepfe.join(", ")}`); + + const los = await luna.seite.$eval("#neu-los", (b) => b.disabled); + ok(los, "der Knopf ist gesperrt, solange niemand gewählt ist"); + + await luna.seite.click('.chat-wahl__knopf[data-rolle="scout"]'); + await luna.seite.waitForTimeout(200); + const frei = await luna.seite.$eval("#neu-los", (b) => !b.disabled); + const beschriftung = await luna.seite.$eval("#neu-los", (b) => b.textContent.trim()); + ok(frei && beschriftung === "Schreiben", + `nach der Wahl ist er frei und heißt "${beschriftung}"`); + + /* Der Gruppenname erscheint erst ab zwei -- vorher wäre er eine + Frage ohne Anlass. */ + const nameVersteckt = await luna.seite.$eval("#gruppenname-zeile", (e) => e.hidden); + ok(nameVersteckt, "bei einer Person wird nicht nach einem Gruppennamen gefragt"); + + await luna.seite.click("#neu-los"); + await luna.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 }); + const titel = await luna.seite.$eval("#verlauf-titel", (e) => e.textContent); + ok(titel === "Patrick", `das Gespräch ist offen: "${titel}"`); +} + +/* ======================================================================= + 2. DER KERN: schreiben, und es kommt an — ohne Neuladen + ======================================================================= */ +melde("\n=== Zwei Browser, eine Nachricht ==="); +const pat = await alsRolle("scout", "CODE-SCOU-0001"); +{ + /* Patrick öffnet dasselbe Gespräch. */ + await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 }); + await pat.seite.click(".chat-raum__knopf"); + await pat.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 }); + await pat.seite.waitForTimeout(600); + + const vorher = (await pat.seite.$$(".chat-nachricht")).length; + + /* Luna schreibt. */ + await luna.seite.fill("#text", "Hey Patrick, kurze Frage zum Start-Check!"); + await luna.seite.click("#senden"); + + /* SOFORT bei Luna sichtbar -- vor jeder Serverantwort. Deshalb wird + hier bewusst nur ganz kurz gewartet. */ + await luna.seite.waitForTimeout(120); + const beiLuna = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent)); + ok(beiLuna.some((t) => /Start-Check/.test(t)), + "die eigene Nachricht steht sofort da, ohne auf den Server zu warten"); + + /* UND SIE KOMMT BEI PATRICK AN, ohne dass er etwas tut. */ + const angekommen = await pat.seite.waitForFunction( + () => [...document.querySelectorAll(".chat-nachricht__text")] + .some((t) => /Start-Check/.test(t.textContent)), + { timeout: 8000 }).then(() => true).catch(() => false); + ok(angekommen, angekommen + ? "sie erscheint bei Patrick von selbst — kein Neuladen nötig" + : "sie kam bei Patrick NICHT an (der Strom liefert nicht)"); + + const nachher = (await pat.seite.$$(".chat-nachricht")).length; + ok(nachher === vorher + 1, `Patrick sieht ${nachher} statt ${vorher} Nachrichten`); + + /* Und die Seiten sind sich einig, WER sie geschrieben hat. */ + const beiPatSelbst = await pat.seite.$eval(".chat-nachricht:last-of-type", + (e) => e.dataset.selbst === "ja"); + const beiLunaSelbst = await luna.seite.$eval(".chat-nachricht:last-of-type", + (e) => e.dataset.selbst === "ja"); + ok(!beiPatSelbst && beiLunaSelbst, + "bei Luna steht sie rechts (eigene), bei Patrick links (fremde)"); +} + +/* ======================================================================= + 3. Und zurück + ======================================================================= */ +melde("\n=== Patrick antwortet ==="); +{ + await pat.seite.fill("#text", "Klar, schieß los!"); + await pat.seite.click("#senden"); + + const zurueck = await luna.seite.waitForFunction( + () => [...document.querySelectorAll(".chat-nachricht__text")] + .some((t) => /schieß los/.test(t.textContent)), + { timeout: 8000 }).then(() => true).catch(() => false); + ok(zurueck, "die Antwort erscheint bei Luna von selbst"); + + /* Der Verlauf steht in der richtigen Reihenfolge -- eine + Unterhaltung, die durcheinandergerät, ist unbrauchbar. */ + const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent)); + ok(/Start-Check/.test(texte[0]) && /schieß los/.test(texte[1]), + `Reihenfolge stimmt: "${texte.map((t) => t.slice(0, 18)).join('" → "')}"`); +} + +/* ======================================================================= + 4. Die Zahl in der Kopfleiste — auf einer ANDEREN Seite + ======================================================================= */ +melde("\n=== Erfährt man davon, ohne im Chat zu sein? ==="); +{ + /* Patrick geht woandershin. Genau dort muss die Zahl erscheinen -- + sonst merkt er von einer Nachricht nichts. */ + await pat.seite.goto(`${BASIS}/workspace/aufgaben.html`, { waitUntil: "networkidle" }); + await pat.seite.waitForTimeout(900); + + const knopfDa = await pat.seite.$("#chat-knopf, .chat-knopf"); + ok(!!knopfDa, "auf der Aufgabenseite steht ein Chat-Knopf in der Kopfleiste"); + + const vorher = await pat.seite.$eval(".chat-knopf__zahl", + (e) => (e.hidden ? "0" : e.textContent)); + ok(vorher === "0", `vorher zeigt er nichts an (${vorher})`); + + await luna.seite.fill("#text", "Wann hast du Zeit für einen Call?"); + await luna.seite.click("#senden"); + + const erschienen = await pat.seite.waitForFunction(() => { + const z = document.querySelector(".chat-knopf__zahl"); + return z && !z.hidden && z.textContent === "1"; + }, { timeout: 8000 }).then(() => true).catch(() => false); + ok(erschienen, erschienen + ? "die Zahl 1 erscheint von selbst in der Kopfleiste" + : "die Zahl erscheint NICHT — man erfährt nichts von der Nachricht"); + + /* Und der Knopf selbst trägt Farbe -- eine kleine Zahl allein + übersieht man beim Überfliegen. */ + const markiert = await pat.seite.$eval(".chat-knopf", (e) => e.dataset.neu === "ja"); + ok(markiert, "der Knopf ist zusätzlich hervorgehoben, nicht nur die Zahl"); +} + +/* ======================================================================= + 5. Lesen nimmt die Zahl wieder weg + ======================================================================= */ +melde("\n=== Gelesen ==="); +{ + await pat.seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" }); + await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 }); + + const zahlInListe = await pat.seite.$eval(".chat-raum__zahl", (e) => e.textContent) + .catch(() => null); + ok(zahlInListe === "1", `in der Gesprächsliste steht die Zahl (${zahlInListe})`); + + await pat.seite.click(".chat-raum__knopf"); + await pat.seite.waitForTimeout(1200); + + const weg = await pat.seite.$(".chat-raum__zahl"); + ok(!weg, "nach dem Öffnen ist sie weg"); + + /* Und sie bleibt weg -- auch nach einem Neuladen. Wäre "gelesen" nur + eine Anzeige im Browser, käme die Zahl beim nächsten Aufruf zurück. */ + await pat.seite.reload({ waitUntil: "networkidle" }); + await pat.seite.waitForTimeout(1000); + const wiederDa = await pat.seite.$(".chat-raum__zahl"); + ok(!wiederDa, "auch nach dem Neuladen bleibt sie weg — es ist wirklich gespeichert"); +} + +/* ======================================================================= + 6. Zurücknehmen, im Bild + ======================================================================= */ +melde("\n=== Zurücknehmen ==="); +{ + await luna.seite.bringToFront(); + const vorher = (await luna.seite.$$(".chat-nachricht")).length; + + /* Playwright bestätigt den confirm-Dialog nicht von selbst. */ + luna.seite.once("dialog", (dlg) => dlg.accept()); + await luna.seite.click(".chat-nachricht[data-selbst='ja']:last-of-type .chat-nachricht__weg-knopf"); + await luna.seite.waitForTimeout(1000); + + const nachher = (await luna.seite.$$(".chat-nachricht")).length; + ok(nachher === vorher, + `die Zeile bleibt stehen (${nachher} statt ${vorher}) — kein Loch im Verlauf`); + + const hinweis = await luna.seite.$(".chat-nachricht__weg"); + ok(!!hinweis, "an ihrer Stelle steht 'Nachricht zurückgenommen'"); + + /* Und beim Gegenüber verschwindet der Text ebenfalls -- von selbst. */ + const beiPat = await pat.seite.waitForFunction( + () => !![...document.querySelectorAll(".chat-nachricht__weg")].length, + { timeout: 8000 }).then(() => true).catch(() => false); + ok(beiPat, "auch bei Patrick ist der Text weg, ohne Neuladen"); +} + +/* ======================================================================= + GEGENPROBE — misst die Prüfung überhaupt etwas? + ======================================================================= */ +melde("\n=== Gegenprobe ==="); +{ + /* Träfe der Selektor nichts, wären alle Haken oben wertlos. */ + const vorher = (await luna.seite.$$(".chat-nachricht")).length; + await luna.seite.evaluate(() => { + for (const n of document.querySelectorAll(".chat-nachricht")) n.remove(); + }); + const nachher = (await luna.seite.$$(".chat-nachricht")).length; + ok(vorher >= 3 && nachher === 0, + `der Selektor trifft (${vorher} gefunden, nach dem Entfernen ${nachher})`); + + /* Und ein Text, der nie geschrieben wurde, darf NICHT gefunden + werden -- sonst prüfte "kam an" gar nichts. */ + await luna.seite.reload({ waitUntil: "networkidle" }); + await luna.seite.waitForTimeout(1200); + const erfunden = await luna.seite.$$eval(".chat-nachricht__text", + (t) => t.some((x) => /Diesen Satz gab es nie/.test(x.textContent))); + ok(!erfunden, "ein nie geschriebener Satz wird nicht gefunden"); +} + +await luna.ctx.close(); +await pat.ctx.close(); +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg. + + Diese Pruefung startet einen Express-Server, und der laeuft von + selbst weiter: offene Verbindungen, ein Zeitgeber fuer die + Sicherung, bei dieser Pruefung zusaetzlich die offenen + Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig + darauf, dass all das zu Ende geht -- der Lauf lief in die + Zeitueberschreitung, obwohl alle Pruefungen schon durch waren. + + Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von + fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht + mehr: Die Browser sind zu, bevor diese Zeile laeuft. */ +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-chat.mjs b/server/pruef-chat.mjs new file mode 100644 index 00000000..5c9da11d --- /dev/null +++ b/server/pruef-chat.mjs @@ -0,0 +1,403 @@ +/* CHAT — wer darf mit wem, und wer bekommt nichts zu sehen. + + Wunsch Filipe, 06.09.2026. Auf Nachfrage entschieden: Zweier- + Gespräche UND Gruppen; Manager und Scouts dürfen sich auch ohne + gemeinsamen Creator schreiben. + + WARUM DIESE PRÜFUNG ZUERST KAM, vor der Oberfläche: Ein Chat ist der + Ort, an dem Leute Dinge schreiben, die nicht jeder lesen soll. Ein + Fehler in der Sichtbarkeit ist hier teurer als überall sonst im + Haus -- eine falsche Zahl auf einer Kachel ärgert, ein mitgelesenes + Gespräch ist ein Vertrauensbruch. + + GEMESSEN WIRD: + 1. Die Regel selbst: Wer erscheint in wessen Partnerliste? + 2. Kann man sie umgehen, indem man die Anfrage von Hand baut? + 3. Kommt man an einen fremden Verlauf, wenn man die Raumnummer rät? + 4. Ist eine Gruppe ein Schlupfloch zu jemandem, den man einzeln + nicht anschreiben dürfte? + 5. Und die Alltagsfragen: Gibt es je Paar genau EIN Gespräch? + Zählt "ungelesen" richtig? Bleibt ein Zurücknehmen sichtbar? + + GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten + Seite versucht. Eine Prüfung, die immer "verboten" sagt, beweist + nichts. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-chat-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4313, "die Chatpruefung"); + +process.env.PORT = "4313"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4313"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Ein Bestand mit ECHTER Struktur ------------------------------------ + + Zwei getrennte Ketten, damit "fremd" wirklich fremd ist: + Cigdem (Manager) → Patrick (Scout) → Luna (Creator) + Meral (Manager) → Sven (Scout) → Nora (Creator) + Dazu DogFather über allem. + + Ohne die zweite Kette liesse sich nicht prüfen, ob ein Creator an + einen fremden Creator kommt -- es gäbe schlicht keinen. */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idCigdem = anlegen("Cigdem", "manager", "CODE-MANA-0001"); +const idMeral = anlegen("Meral", "manager", "CODE-MANA-0002"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idSven = anlegen("Sven", "scout", "CODE-SCOU-0002"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +const idNora = anlegen("Nora", "creator", "CODE-CREA-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idSven, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCigdem, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idSven, idMeral, jetzt); + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); + return { ok: a.ok, keks }; +} +const rufe = (keks, weg, koerper, methode = "GET") => + fetch(BASIS + weg, { + method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper), + }); +const holen = async (keks, weg) => (await rufe(keks, weg)).json(); + +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const cigdem = await anmelden("manager", "CODE-MANA-0001"); +const meral = await anmelden("manager", "CODE-MANA-0002"); +const pat = await anmelden("scout", "CODE-SCOU-0001"); +const luna = await anmelden("creator", "CODE-CREA-0001"); +const nora = await anmelden("creator", "CODE-CREA-0002"); +ok([dogi, cigdem, meral, pat, luna, nora].every((x) => x.ok && x.keks), + "alle sechs Rollen sind angemeldet"); + +/* ======================================================================= + 1. Die Regel: wer erscheint in wessen Liste? + ======================================================================= */ +console.log("\n=== Mit wem kann man schreiben? ==="); +{ + const namen = async (keks) => { + const { personen } = await holen(keks, "/workspace/api/chat/partner"); + return (personen || []).map((p) => p.name).sort(); + }; + + const beiLuna = await namen(luna.keks); + /* Ein Creator: sein Scout, dessen Manager, DogFather. Kein fremder + Creator, kein fremder Scout. */ + ok(JSON.stringify(beiLuna) === JSON.stringify(["Cigdem", "Filipe", "Patrick"]), + `Creator Luna: ${beiLuna.join(", ")}`); + ok(!beiLuna.includes("Nora"), + "Luna sieht die andere Creatorin NICHT — Creator bleiben getrennt"); + ok(!beiLuna.includes("Sven") && !beiLuna.includes("Meral"), + "und auch nicht den fremden Scout oder dessen Manager"); + + const beiPat = await namen(pat.keks); + /* Ein Scout: sein Creator, DogFather -- UND das ganze Team + (Entscheidung vom 06.09.2026: "für Absprachen im Team"). */ + ok(beiPat.includes("Luna") && beiPat.includes("Filipe"), + `Scout Patrick erreicht seinen Creator und DogFather`); + ok(beiPat.includes("Meral") && beiPat.includes("Sven"), + `und das Team: ${beiPat.join(", ")}`); + ok(!beiPat.includes("Nora"), + "aber NICHT die fremde Creatorin — die Öffnung gilt nur fürs Team"); + + const beiDogi = await namen(dogi.keks); + ok(beiDogi.length === 6, `DogFather erreicht alle ${beiDogi.length} anderen`); + ok(!beiDogi.includes("Filipe"), "aber nicht sich selbst"); +} + +/* ======================================================================= + 2. Lässt sich die Regel von Hand umgehen? + ======================================================================= */ +console.log("\n=== Die Regel sitzt im Server, nicht in der Oberfläche ==="); +{ + /* Luna baut die Anfrage selbst und will Nora anschreiben. */ + const a = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idNora }, "POST"); + ok(a.status === 403, `Luna → Nora von Hand: abgelehnt (${a.status})`); + + const b = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idSven }, "POST"); + ok(b.status === 403, `Luna → fremder Scout: abgelehnt (${b.status})`); + + /* GEGENPROBE: Zu ihrem eigenen Scout geht es. Ohne diesen Nachweis + bewiese "403" nur, dass die Route überhaupt ablehnt. */ + const c = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST"); + ok(c.ok, `Luna → ihr eigener Scout: geht (${c.status})`); + + /* Und sich selbst anschreiben ergibt keinen Sinn. */ + const e = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idLuna }, "POST"); + ok(e.status === 403, `sich selbst anschreiben: abgelehnt (${e.status})`); +} + +/* ======================================================================= + 3. Ein fremder Verlauf, mit geratener Nummer + ======================================================================= */ +console.log("\n=== Kommt man an fremde Gespräche? ==="); +let raumLunaPat = 0; +{ + const { raum_id } = await (await rufe(luna.keks, "/workspace/api/chat/direkt", + { mit: idPat }, "POST")).json(); + raumLunaPat = raum_id; + ok(Number.isInteger(raumLunaPat) && raumLunaPat > 0, `Gespräch Luna↔Patrick ist #${raumLunaPat}`); + + await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "Vertraulich: mein Umsatz war 3200." }, "POST"); + + /* Nora rät die Nummer. */ + const a = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); + ok(a.status === 404, `Fremde liest Verlauf: ${a.status} (404, nicht 403 — die Nummer soll nichts verraten)`); + + /* Und sie versucht hineinzuschreiben. */ + const b = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "Hallo?" }, "POST"); + ok(b.status === 404, `Fremde schreibt hinein: ${b.status}`); + + /* Auch als gelesen markieren geht nicht -- das wäre sonst ein Weg, + die Existenz eines Raums zu erfahren. */ + const c = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`, + { bis: 999 }, "PUT"); + ok(c.status === 404, `Fremde markiert als gelesen: ${c.status}`); + + /* GEGENPROBE: Patrick, der drin ist, kommt heran. */ + const e = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); + ok(e.nachrichten?.length === 1 && /3200/.test(e.nachrichten[0].text), + `Patrick liest den Verlauf (${e.nachrichten?.length} Nachricht)`); + + /* Selbst DogFather kommt nicht ungefragt in ein fremdes Gespräch. + Das ist Absicht: Ein Chat, in dem der Chef stillschweigend mitliest, + ist kein Chat. Er darf jederzeit selbst schreiben -- aber sichtbar. */ + const f = await rufe(dogi.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); + ok(f.status === 404, + `auch DogFather liest fremde Gespräche NICHT mit (${f.status}) — er ist kein Teilnehmer`); +} + +/* ======================================================================= + 4. Genau EIN Gespräch je Paar + ======================================================================= */ +console.log("\n=== Ein Paar, ein Gespräch ==="); +{ + /* Von der anderen Seite geöffnet muss derselbe Raum kommen -- sonst + schreibt einer ins Leere, während der andere wartet. */ + const { raum_id: vonPat } = await (await rufe(pat.keks, "/workspace/api/chat/direkt", + { mit: idLuna }, "POST")).json(); + ok(vonPat === raumLunaPat, + `Patrick öffnet dasselbe Gespräch (#${vonPat} = #${raumLunaPat}), keine zweite Kopie`); + + /* Und mehrfaches Öffnen legt nichts Neues an. */ + for (let i = 0; i < 3; i++) { + await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST"); + } + const p = new DatabaseSync(process.env.WORKSPACE_DB); + const n = p.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'direkt'").get().n; + p.close(); + ok(n === 1, `nach fünfmaligem Öffnen: ${n} Gespräch in der Datenbank`); +} + +/* ======================================================================= + 5. Gruppen — und ob sie ein Schlupfloch sind + ======================================================================= */ +console.log("\n=== Gruppen ==="); +{ + /* DER PUNKT: Luna darf Nora nicht einzeln anschreiben. Kann sie es + über eine Gruppe? */ + const a = await rufe(luna.keks, "/workspace/api/chat/gruppe", + { name: "Umweg", mit: [idPat, idNora] }, "POST"); + ok(a.status === 403, + `eine Gruppe ist KEIN Schlupfloch: ${a.status} (Nora war dabei)`); + + /* GEGENPROBE: Ohne Nora geht dieselbe Gruppe. */ + const b = await rufe(luna.keks, "/workspace/api/chat/gruppe", + { name: "Luna, Patrick & Cigdem", mit: [idPat, idCigdem] }, "POST"); + ok(b.status === 201, `mit erlaubten Leuten: angelegt (${b.status})`); + const { raum_id: gruppe } = await b.json(); + + const sicht = await holen(luna.keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`); + ok(sicht.raum?.teilnehmer?.length === 3, + `die Gruppe hat ${sicht.raum?.teilnehmer?.length} Mitglieder`); + ok(sicht.raum?.name === "Luna, Patrick & Cigdem", + `und ihren Namen: "${sicht.raum?.name}"`); + + /* Wer sie angelegt hat, führt sie. */ + const chefin = sicht.raum.teilnehmer.find((t) => t.leitung); + ok(chefin?.id === idLuna, `die Leitung liegt bei ${chefin?.name}`); + + /* Ohne Namen keine Gruppe. */ + const c = await rufe(luna.keks, "/workspace/api/chat/gruppe", + { name: "", mit: [idPat] }, "POST"); + ok(c.status === 400, `ohne Namen: abgelehnt (${c.status})`); + + /* Und ohne Leute auch nicht. */ + const e = await rufe(luna.keks, "/workspace/api/chat/gruppe", + { name: "Allein", mit: [] }, "POST"); + ok(e.status === 400, `ohne Mitglieder: abgelehnt (${e.status})`); + + /* Nora sieht die Gruppe nicht in ihrer Liste. */ + const { raeume } = await holen(nora.keks, "/workspace/api/chat/raeume"); + ok(!(raeume || []).some((r) => r.id === gruppe), + `Nora sieht die fremde Gruppe nicht (${raeume?.length || 0} eigene Gespräche)`); +} + +/* ======================================================================= + 6. Ungelesen — die Zahl, auf die man sich verlässt + ======================================================================= */ +console.log("\n=== Ungelesene Nachrichten ==="); +{ + const vorher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen; + + await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "Erste" }, "POST"); + await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "Zweite" }, "POST"); + + const nachher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen; + ok(nachher === vorher + 2, `Patrick: ${vorher} → ${nachher} ungelesen`); + + /* Die EIGENEN zählen nicht mit -- sonst stünde nach jeder eigenen + Nachricht eine rote Zahl an der eigenen Kopfleiste. */ + const beiLuna = (await holen(luna.keks, "/workspace/api/chat/ungelesen")).ungelesen; + ok(beiLuna === 0, `Luna hat ${beiLuna} ungelesene (ihre eigenen zählen nicht)`); + + /* Lesen setzt sie zurück. */ + const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); + const letzteId = verlauf.nachrichten.at(-1).id; + await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`, + { bis: letzteId }, "PUT"); + const danach = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen; + ok(danach === 0, `nach dem Lesen: ${danach}`); + + /* NUR VORWÄRTS: Ein verspäteter Aufruf mit einer alten Nummer darf + den Stand nicht zurückdrehen -- sonst stünde Gelesenes wieder als + ungelesen da. */ + await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`, { bis: 1 }, "PUT"); + const zurueck = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen; + ok(zurueck === 0, `ein alter "gelesen bis 1" dreht nichts zurück (${zurueck})`); +} + +/* ======================================================================= + 7. Zurücknehmen + ======================================================================= */ +console.log("\n=== Eine Nachricht zurücknehmen ==="); +{ + const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "Das war ein Versehen" }, "POST"); + const { nachricht } = await a.json(); + + /* Patrick darf sie NICHT zurücknehmen -- fremde Worte aus einem + Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff in + die Geschichte. */ + const b = await rufe(pat.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE"); + ok(b.status === 403, `fremde Nachricht zurücknehmen: abgelehnt (${b.status})`); + + /* Auch DogFather nicht. */ + const c = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE"); + ok(c.status === 404 || c.status === 403, + `auch DogFather nicht (${c.status})`); + + /* Die eigene schon. */ + const e = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE"); + ok(e.ok, `die eigene: geht (${e.status})`); + + /* UND SIE BLEIBT SICHTBAR -- als zurückgenommen, nicht als Loch. */ + const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); + const weg = verlauf.nachrichten.find((n) => n.id === nachricht.id); + ok(weg && weg.zurueckgenommen && weg.text === null, + `sie steht weiterhin im Verlauf, als zurückgenommen und ohne Text`); + ok(!JSON.stringify(verlauf).includes("Das war ein Versehen"), + "der Text ist wirklich weg, nicht nur ausgeblendet"); +} + +/* ======================================================================= + 8. Grenzen der Eingabe + ======================================================================= */ +console.log("\n=== Was nicht hineingeht ==="); +{ + for (const [was, koerper, status] of [ + ["leerer Text", { text: "" }, 400], + ["nur Leerzeichen", { text: " " }, 400], + ["ohne Feld", {}, 400], + ["zu lang (4001)", { text: "z".repeat(4001) }, 400], + ]) { + const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + koerper, "POST"); + ok(a.status === status, `${was}: ${a.status}`); + } + /* GEGENPROBE: 4000 Zeichen gehen. */ + const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, + { text: "z".repeat(4000) }, "POST"); + ok(a.status === 201, `genau 4000 Zeichen: ${a.status} — die Grenze ist nicht zu streng`); +} + +/* ======================================================================= + 9. Ohne Anmeldung gar nichts + ======================================================================= */ +console.log("\n=== Ohne Anmeldung ==="); +{ + for (const [was, weg, methode] of [ + ["Partnerliste", "/workspace/api/chat/partner", "GET"], + ["Gesprächsliste", "/workspace/api/chat/raeume", "GET"], + ["Verlauf", `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, "GET"], + ["Ungelesen", "/workspace/api/chat/ungelesen", "GET"], + ["Strom", "/workspace/api/chat/strom", "GET"], + ]) { + const a = await fetch(BASIS + weg, { method: methode }); + ok(a.status === 401, `${was}: ${a.status}`); + } +} + +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg. + + Diese Pruefung startet einen Express-Server, und der laeuft von + selbst weiter: offene Verbindungen, ein Zeitgeber fuer die + Sicherung, bei dieser Pruefung zusaetzlich die offenen + Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig + darauf, dass all das zu Ende geht -- der Lauf lief in die + Zeitueberschreitung, obwohl alle Pruefungen schon durch waren. + + Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von + fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht + mehr: Die Browser sind zu, bevor diese Zeile laeuft. */ +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-chat.js b/server/workspace-chat.js new file mode 100644 index 00000000..3a8705e5 --- /dev/null +++ b/server/workspace-chat.js @@ -0,0 +1,535 @@ +/* ===================================================================== + workspace-chat.js — Gespräche zwischen den Leuten im Workspace. + + Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit + ihren manager nachrichten austauschen können, wie erinnerungen, + fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann + wie verbindung auch läuft, und jeder kann dann eine nachricht + reinschreiben, die andere person bekommt eine benachrichtigung und + kann dann antworten."* + + Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und + Scouts dürfen sich auch ohne gemeinsamen Creator schreiben. + + --------------------------------------------------------------------- + DIE DREI REGELN, die alles andere tragen + + 1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in + workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route + fragt dieselbe Funktion -- eine zweite Regel wäre die sichere + Zusage, dass beide irgendwann auseinanderlaufen. + + 2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft + wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst + könnte man eine Raumnummer erraten und mitlesen. + + 3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im + Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas + zurückgenommen hat -- und in einem Gespräch, in dem Absprachen + stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt. + + --------------------------------------------------------------------- + WAS BEWUSST FEHLT + + Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache + nachträglich umschreiben kann, macht den Verlauf wertlos -- + zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine + Spur. (Zurücknehmen selbst ist da, siehe weg_am.) + ===================================================================== */ + +import express from "express"; +import { + db, protokolliere, echteIp, sitzungLesen, istDogFather, + schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, +} from "./workspace.js"; + +export const chatRouter = express.Router(); + +const TEXT_MAX = 4000; +const NAME_MAX = 80; +const jetzt = () => new Date().toISOString(); + +/* ---------- Schranke ---------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +chatRouter.use("/workspace/api/chat", angemeldet); + +/* ===================================================================== + Bausteine + ===================================================================== */ + +/** Ist die Person in diesem Raum -- und nicht ausgetreten? + * + * DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf + * liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht, + * macht aus einer Raumnummer einen Schlüssel. */ +function istDrin(raumId, personId) { + try { + const z = db().prepare( + "SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL") + .get(raumId, personId); + return !!z; + } catch { return false; } +} + +/** Alle (noch aktiven) Teilnehmer eines Raums. */ +function teilnehmerVon(raumId) { + return db().prepare(` + SELECT t.person_id AS id, p.name, p.rolle, t.leitung, t.gelesen_bis + FROM chat_teilnehmer t + JOIN personen p ON p.id = t.person_id + WHERE t.raum_id = ? AND t.raus_am IS NULL + ORDER BY ` + ROLLEN_SORTIERUNG + ", p.name").all(raumId); +} + +/** Findet das Zweier-Gespräch der beiden -- oder legt es an. + * + * ES DARF NUR EINES GEBEN. Zwei Gespräche zwischen denselben zwei + * Leuten wären ein Verlauf an zwei Orten: Man schreibt in das eine, + * der andere liest im anderen und wartet. Deshalb wird zuerst + * gesucht, und zwar über die Teilnehmer -- ein "Paarschlüssel" als + * Spalte wäre eine zweite Wahrheit neben chat_teilnehmer. */ +function direktRaum(personA, idB, { anlegen = true } = {}) { + const d = db(); + const vorhanden = d.prepare(` + SELECT r.id FROM chat_raeume r + WHERE r.art = 'direkt' + AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?) + AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?) + AND (SELECT COUNT(*) FROM chat_teilnehmer t WHERE t.raum_id = r.id) = 2`) + .get(personA.id, idB); + if (vorhanden) return vorhanden.id; + if (!anlegen) return null; + + const n = jetzt(); + d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am) VALUES ('direkt',?,?,NULL)") + .run(n, personA.id); + const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; + const rein = d.prepare( + "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)"); + rein.run(raumId, personA.id, n); + rein.run(raumId, idB, n); + return raumId; +} + +/** Wie heißt dieser Raum für DIESE Person? + * + * Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem + * Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für + * eine der beiden Seiten immer falsch. */ +function raumName(raum, teilnehmer, ichId) { + if (raum.art === "gruppe") return raum.name || "Gruppe"; + const andere = teilnehmer.filter((t) => t.id !== ichId); + return andere.map((t) => t.name).join(", ") || "Gespräch"; +} + +/* ===================================================================== + Die Schnittstellen + ===================================================================== */ + +/** Mit wem kann ich ein Gespräch anfangen? */ +chatRouter.get("/workspace/api/chat/partner", (req, res) => { + try { + const ids = schreibbareIds(req.person); + const wo = ids === null + ? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`; + const werte = ids === null ? [req.person.id] : ids; + if (ids !== null && !ids.length) return res.json({ personen: [] }); + + res.json({ + personen: db().prepare( + `SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo} + ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte), + }); + } catch (fehler) { + console.error("[chat] Partner:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Meine Gespräche, das jüngste zuerst. */ +chatRouter.get("/workspace/api/chat/raeume", (req, res) => { + try { + const reihen = db().prepare(` + SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung + FROM chat_raeume r + JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ? + WHERE t.raus_am IS NULL + ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`) + .all(req.person.id); + + const raeume = reihen.map((r) => { + const leute = teilnehmerVon(r.id); + /* Die letzte Nachricht als Vorschau -- ohne sie ist eine + Gesprächsliste nur eine Namensliste. */ + const letzte = db().prepare(` + SELECT n.text, n.erstellt, n.weg_am, p.name AS von + FROM chat_nachrichten n + LEFT JOIN personen p ON p.id = n.person_id + WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id); + const ungelesen = db().prepare(` + SELECT COUNT(*) AS n FROM chat_nachrichten + WHERE raum_id = ? AND id > ? AND person_id <> ?`) + .get(r.id, r.gelesen_bis, req.person.id).n; + + return { + id: r.id, + art: r.art, + name: raumName(r, leute, req.person.id), + leitung: !!r.leitung, + teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })), + letzte: letzte ? { + text: letzte.weg_am ? null : letzte.text.slice(0, 140), + zurueckgenommen: !!letzte.weg_am, + von: letzte.von, erstellt: letzte.erstellt, + } : null, + ungelesen, + }; + }); + res.json({ raeume }); + } catch (fehler) { + console.error("[chat] Räume:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */ +chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + const mit = Number(req.body?.mit); + if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" }); + /* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage + von Hand baut, kommt damit keinen Schritt weiter. */ + if (!darfSchreibenMit(req.person, mit)) { + return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." }); + } + const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit); + if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const raumId = direktRaum(req.person, mit); + res.json({ raum_id: raumId }); + } catch (fehler) { + console.error("[chat] Direkt:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */ +chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + const name = String(req.body?.name ?? "").trim(); + if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." }); + if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." }); + + const roh = Array.isArray(req.body?.mit) ? req.body.mit : []; + const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))] + .filter((id) => id !== req.person.id); + /* JEDE Nummer einzeln gegen die Regel -- eine Gruppe ist kein + Schlupfloch, um jemanden zu erreichen, den man einzeln nicht + anschreiben dürfte. */ + const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id)); + if (verboten.length) { + return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." }); + } + if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." }); + + const d = db(); + const n = jetzt(); + d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe',?,?,?)") + .run(name, n, req.person.id); + const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)") + .run(raumId, req.person.id, n); + const rein = d.prepare( + "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)"); + for (const id of mit) { + if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) rein.run(raumId, id, n); + } + + protokolliere("chat_gruppe_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${raumId} ${name} (${mit.length + 1})`.slice(0, 120), + }); + res.status(201).json({ raum_id: raumId }); + } catch (fehler) { + console.error("[chat] Gruppe:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Der Verlauf eines Gesprächs. */ +chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { + try { + const raumId = Number(req.params.id); + if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); + /* Was man nicht sehen darf, existiert nicht -- 404 statt 403, + sonst liesse sich durch Ausprobieren herausfinden, welche + Gespräche es gibt. */ + if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den + Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man + über `vor`. */ + const vor = Number(req.query.vor) || 0; + const reihen = db().prepare(` + SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle + FROM chat_nachrichten n + LEFT JOIN personen p ON p.id = n.person_id + WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""} + ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId])); + + const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId); + const leute = teilnehmerVon(raumId); + + res.json({ + raum: { + id: raum.id, art: raum.art, + name: raumName(raum, leute, req.person.id), + teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle, leitung: !!t.leitung })), + }, + /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ + nachrichten: reihen.reverse().map((n) => ({ + id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, + text: n.weg_am ? null : n.text, + zurueckgenommen: !!n.weg_am, + erstellt: n.erstellt, + selbst: n.person_id === req.person.id, + })), + }); + } catch (fehler) { + console.error("[chat] Verlauf:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Etwas schreiben. */ +chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, + express.json({ limit: "16kb" }), async (req, res) => { + try { + const raumId = Number(req.params.id); + if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const text = String(req.body?.text ?? "").trim(); + if (!text) return res.status(400).json({ fehler: "leer" }); + if (text.length > TEXT_MAX) { + return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` }); + } + + const d = db(); + const n = jetzt(); + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(raumId, req.person.id, text, n); + const id = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId); + /* Wer schreibt, hat das Eigene gelesen. */ + d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?") + .run(id, raumId, req.person.id); + + const nachricht = { + id, raum_id: raumId, von_id: req.person.id, von: req.person.name, + rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false, + }; + + /* Sofort an alle, die gerade zusehen -- und per Benachrichtigung + an die, die es nicht tun. Beides läuft über chatEreignis(), + damit die Entscheidung "wer bekommt was" an einer Stelle steht. */ + const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id); + chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id)); + + res.status(201).json({ nachricht }); + } catch (fehler) { + console.error("[chat] Schreiben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Bis hierher gelesen. */ +chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, + express.json({ limit: "4kb" }), (req, res) => { + try { + const raumId = Number(req.params.id); + const bis = Number(req.body?.bis); + if (!Number.isInteger(raumId) || !Number.isInteger(bis)) { + return res.status(400).json({ fehler: "ungueltig" }); + } + if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren + Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine + gelesene Nachricht wieder als ungelesen da. */ + db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ? + WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`) + .run(bis, raumId, req.person.id, bis); + res.json({ ok: true }); + } catch (fehler) { + console.error("[chat] Gelesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/** Eine eigene Nachricht zurücknehmen. */ +chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const n = db().prepare("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id); + if (!n || !istDrin(n.raum_id, req.person.id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + /* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem + Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff + in die Geschichte -- und niemand könnte ihm nachher glauben. */ + if (n.person_id !== req.person.id) { + return res.status(403).json({ fehler: "Nur eigene Nachrichten." }); + } + + db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id); + const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id); + chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg"); + + protokolliere("chat_zurueckgenommen", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id}`, + }); + res.json({ ok: true, empfaenger: empfaenger.length }); + } catch (fehler) { + console.error("[chat] Zurücknehmen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + Sofort zusehen (Server-Sent Events) + ===================================================================== + + WARUM SSE UND NICHT NACHFRAGEN IM TAKT: Ein Chat, der alle fünf + Sekunden nachfragt, ist fünf Sekunden langsam und stellt bei zehn + Angemeldeten 7200 Anfragen in der Stunde -- für Nachrichten, die es + meistens nicht gibt. + + WARUM SSE UND NICHT WEBSOCKET: Hier fliesst alles in EINE Richtung, + vom Server zum Browser. Geschrieben wird über die normale + Schnittstelle. SSE ist dafür gebaut, kommt ohne fremde Bibliothek + aus und übersteht einen Verbindungsabriss von selbst -- der Browser + verbindet sich neu, ohne dass man dafür Code schreibt. + + Der Browser holt sich nach einem Abriss die Lücke über den normalen + Verlauf nach; deshalb ist hier keine Nachrichtenwarteschlange nötig. */ + +/** Wer sieht gerade zu: Personennummer → Menge offener Verbindungen. + * Eine Person kann an zwei Geräten sitzen. */ +const zuschauer = new Map(); + +function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") { + /* 1. An alle offenen Verbindungen der Beteiligten. */ + const leute = teilnehmerVon(raumId).map((t) => t.id); + for (const personId of leute) { + for (const antwort of zuschauer.get(personId) || []) { + try { + antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`); + } catch { /* die Verbindung räumt sich selbst ab */ } + } + } + + /* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite + offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine + Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er + Benachrichtigungen abschaltet. */ + for (const personId of benachrichtigen) { + if ((zuschauer.get(personId) || []).length) continue; + void meldeNeueNachricht(personId, raumId, nachricht); + } +} + +/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll + * auch dann laufen, wenn am Push-Teil etwas klemmt. */ +async function meldeNeueNachricht(personId, raumId, nachricht) { + try { + const { benachrichtige } = await import("./workspace-push.js"); + await benachrichtige(personId, "chat_nachricht", { + titel: `Nachricht von ${nachricht.von}`, + /* Der Anfang der Nachricht steht in der Meldung -- eine + Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum + Öffnen, um zu erfahren, ob es wichtig war. */ + text: String(nachricht.text || "").slice(0, 120), + ziel: `/workspace/chat.html?raum=${raumId}`, + }); + } catch (fehler) { + console.error("[chat] Benachrichtigung:", fehler?.message); + } +} + +chatRouter.get("/workspace/api/chat/strom", (req, res) => { + res.set({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + /* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx) + den Strom -- und dann kommt die Nachricht erst, wenn genug + zusammen ist. Genau das Gegenteil des Zwecks. */ + "X-Accel-Buffering": "no", + }); + res.flushHeaders?.(); + res.write("retry: 3000\n\n"); + + const personId = req.person.id; + if (!zuschauer.has(personId)) zuschauer.set(personId, new Set()); + zuschauer.get(personId).add(res); + + /* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche + Vermittlungsstellen die Verbindung nach einer Minute Stille -- der + Browser verbindet sich dann zwar neu, aber jedes Mal mit einer + Lücke von ein paar Sekunden. */ + const puls = setInterval(() => { + try { res.write(": puls\n\n"); } catch { /* gleich weg */ } + }, 25_000); + puls.unref?.(); + + const aufraeumen = () => { + clearInterval(puls); + const menge = zuschauer.get(personId); + if (menge) { + menge.delete(res); + if (!menge.size) zuschauer.delete(personId); + } + }; + req.on("close", aufraeumen); + req.on("error", aufraeumen); +}); + +/** Wie viele Nachrichten warten insgesamt auf mich? + * Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */ +chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => { + try { + const z = db().prepare(` + SELECT COUNT(*) AS n FROM chat_nachrichten m + JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ? + WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`) + .get(req.person.id, req.person.id); + res.json({ ungelesen: z?.n || 0 }); + } catch (fehler) { + console.error("[chat] Ungelesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */ +export function siehtZu(personId) { + return (zuschauer.get(Number(personId)) || []).size > 0; +} diff --git a/server/workspace-push.js b/server/workspace-push.js index 29a66c6e..a5c70756 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -74,6 +74,10 @@ export const ARTEN = [ { schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true }, { schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true }, { schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true }, + /* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art, + die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht + zuerst sie. */ + { schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true }, { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true }, ]; const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel)); diff --git a/server/workspace.js b/server/workspace.js index bdc179b3..ec3d5501 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -666,6 +666,82 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_aufgaben_notizen ON aufgaben_notizen (aufgabe_id); + /* ================================================================= + CHAT (06.09.2026) + + Wunsch Filipe: *"eine kategorie chat, wo die creator mit ihren + manager nachrichten austauschen können, wie erinnerungen, fragen + und noch vieles mehr, wo jeder immer mit denen schreiben kann + wie verbindung auch läuft."* Auf Nachfrage: Zweier-Gespraeche + UND Gruppen, und Manager/Scouts duerfen sich auch ohne + gemeinsamen Creator schreiben. + + DREI TABELLEN, und die Aufteilung hat einen Grund: + + chat_raeume das Gespraech selbst + chat_teilnehmer wer darin ist -- und bis wohin er gelesen hat + chat_nachrichten was gesagt wurde + + WARUM DIE TEILNEHMER EINE EIGENE TABELLE SIND und nicht zwei + Spalten am Raum: Ein Zweier-Gespraech kaeme mit "person_a, + person_b" aus, eine Gruppe nicht. Zwei verschiedene Bauweisen + fuer dieselbe Sache waeren der sichere Weg dazu, dass eine + Abfrage die eine Haelfte vergisst. So ist ein Zweier-Gespraech + schlicht eine Gruppe mit zwei Leuten. + + WARUM "gelesen_bis" AM TEILNEHMER haengt und nicht an der + Nachricht: Sonst braeuchte es je Person und Nachricht eine + Zeile -- bei drei Leuten und tausend Nachrichten dreitausend + Eintraege, nur um "gelesen" zu wissen. Eine Nummer je Person + und Raum genuegt: Alles darunter ist gelesen. */ + CREATE TABLE IF NOT EXISTS chat_raeume ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + art TEXT NOT NULL DEFAULT 'direkt' + CHECK (art IN ('direkt','gruppe')), + /* Nur Gruppen haben einen Namen. Ein Zweier-Gespraech heisst + immer nach dem Gegenueber -- und zwar fuer jeden anders. Ein + gespeicherter Name waere fuer eine der beiden Seiten falsch. */ + name TEXT, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + /* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn + braeuchte die Gespraechsliste je Raum eine Unterabfrage -- + bei zwanzig Raeumen zwanzig Abfragen fuer eine Liste. */ + letzte_am TEXT + ); + CREATE INDEX IF NOT EXISTS idx_chat_raeume_letzte ON chat_raeume (letzte_am DESC); + + CREATE TABLE IF NOT EXISTS chat_teilnehmer ( + raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + seit TEXT NOT NULL, + /* Bis zu welcher Nachricht diese Person gelesen hat. */ + gelesen_bis INTEGER NOT NULL DEFAULT 0, + /* Wer eine Gruppe angelegt hat, darf Leute hinzufuegen und + entfernen. In Zweier-Gespraechen bedeutungslos. */ + leitung INTEGER NOT NULL DEFAULT 0, + /* Verlassene Gruppen: Die Zeile bleibt mit einem Datum stehen, + damit der Verlauf bis dahin lesbar bleibt und niemand + rueckwirkend aus der Geschichte verschwindet. */ + raus_am TEXT, + PRIMARY KEY (raum_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_chat_teilnehmer_person ON chat_teilnehmer (person_id); + + CREATE TABLE IF NOT EXISTS chat_nachrichten ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + raum_id INTEGER NOT NULL REFERENCES chat_raeume(id) ON DELETE CASCADE, + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + text TEXT NOT NULL, + erstellt TEXT NOT NULL, + /* Zurueckgenommen: Der Text wird geleert, die Zeile bleibt. + Ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis, + dass hier etwas zurueckgenommen wurde. */ + weg_am TEXT + ); + CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum + ON chat_nachrichten (raum_id, id); + /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je Creator, entsteht erst beim ersten Speichern. admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an @@ -1146,6 +1222,11 @@ const GESCHUETZT = { "/workspace/uebersicht.html": null, "/workspace/content.html": null, "/workspace/aufgaben.html": null, + /* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet + schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der + die Seite nicht öffnen dürfte, könnte auch nicht antworten, und + genau das Antworten ist der Zweck. */ + "/workspace/chat.html": null, /* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit jeher ("Manager -- dieselben Rechte, ausser der Personenverwaltung"), die Schranke hielt sich nur nicht daran. @@ -1648,6 +1729,59 @@ export function einladbareIds(person) { return [...new Set([...sichtbar, ...betreuerIds(person), ...chefs])]; } +/** MIT WEM DARF ICH SCHREIBEN? (06.09.2026, für den Chat) + * + * Entschieden von Filipe: entlang der Betreuung -- PLUS Manager und + * Scouts untereinander, auch ohne gemeinsamen Creator, "für Absprachen + * im Team". + * + * WARUM NICHT einladbareIds() WIEDERVERWENDEN, obwohl es fast passt: + * Dort geht es darum, wen man zu einem TERMIN dazustellen darf. Das + * ist harmlos -- die Person sieht einen Eintrag im Kalender. Ein Chat + * ist etwas anderes: Er legt ein dauerhaftes Gespräch an, schickt eine + * Benachrichtigung und macht den eigenen Namen sichtbar. Zwei Fragen, + * zwei Funktionen -- sonst verschiebt eine spätere Änderung an der + * einen stillschweigend die andere. + * + * WAS SICH DADURCH ÄNDERT gegenüber der Terminwahl: Ein Manager + * erreicht jetzt auch Scouts, die nicht seine sind, und umgekehrt. + * Ein Creator NICHT -- er bleibt bei seinen Betreuern und DogFather. + * Creator untereinander bleiben getrennt; sie sollen die Namen der + * anderen Creator weiterhin nicht sehen. + * + * null = alle (nur DogFather). */ +export function schreibbareIds(person) { + if (!person) return []; + if (istDogFather(person)) return null; + + const basis = new Set(einladbareIds(person) || []); + + /* Das Team untereinander. Ein Scout, der eine Frage zu einem + fremden Creator hat, muss den zuständigen Manager erreichen können + -- sonst läuft alles über DogFather, und das ist genau der + Flaschenhals, den ein Chat auflösen soll. */ + if (person.rolle === "manager" || person.rolle === "scout") { + try { + for (const z of db().prepare( + "SELECT id FROM personen WHERE rolle IN ('manager','scout') AND aktiv = 1").all()) { + basis.add(z.id); + } + } catch { /* im Zweifel nur die Betreuungskette */ } + } + + /* Sich selbst nicht -- ein Gespräch mit sich allein ist keines. */ + basis.delete(person.id); + return [...basis]; +} + +/** Darf diese Person mit jener schreiben? Eine Frage, eine Antwort -- + * damit die Regel nicht an fünf Stellen einzeln nachgebaut wird. */ +export function darfSchreibenMit(person, andereId) { + const ids = schreibbareIds(person); + if (ids === null) return Number(andereId) !== Number(person.id); + return ids.includes(Number(andereId)); +} + /** SQL-Baustein daraus: "diese Spalte ist eine Person, die ich sehen * darf". Gibt null zurück, wenn nicht eingeschränkt werden muss. */ export function personenWo(person, spalte) { diff --git a/workspace/assets/css/calls.css b/workspace/assets/css/calls.css index 738a9368..f22c9abd 100644 --- a/workspace/assets/css/calls.css +++ b/workspace/assets/css/calls.css @@ -126,3 +126,101 @@ @media (prefers-reduced-motion: reduce) { .call, .todo__weg { transition: none; } } + +/* ---------- Aufklappbare Zeitraum-Blöcke -------------------------------- + + Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27": + *"die liste da soll kategorisiert sein und werden mit einem button + zum auf und zu machen."* + + Siebenundzwanzig Karten am Stück beantworten keine Frage -- man + scrollt an allem vorbei, was diese Woche ansteht, um zu sehen, dass + der Rest im November liegt. Jetzt: Heute · Diese Woche · Nächste + Woche · Später, und offen ist nur der erste gefüllte Block. + + Gestaltet wie der Bereich "Abgebrochen" auf dem Aufgabenbrett -- + dasselbe Bauteil soll auch gleich aussehen. */ + +.zeitblock { + margin-top: 10px; + border: 1px solid var(--rand); + border-radius: 13px; + background: rgba(255, 255, 255, .018); +} +.zeitblock + .zeitblock { margin-top: 8px; } + +.zeitblock__kopf { + display: flex; align-items: center; gap: 10px; + padding: 12px 15px; + cursor: pointer; + font-size: .8rem; font-weight: 700; + letter-spacing: .05em; text-transform: uppercase; + color: var(--text-still); + /* Das eigene Dreieck von weg -- es wird unten selbst + gezeichnet, damit es links steht und sich beim Öffnen dreht. */ + list-style: none; +} +.zeitblock__kopf::-webkit-details-marker { display: none; } +.zeitblock__kopf:hover { color: var(--text-leise); } +.zeitblock__kopf:focus-visible { + outline: 2px solid var(--akzent); outline-offset: -2px; border-radius: 13px; +} + +.zeitblock__kopf::before { + content: ""; flex: none; + width: 0; height: 0; + border-left: 5px solid currentColor; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transition: transform .18s ease; +} +.zeitblock[open] .zeitblock__kopf::before { transform: rotate(90deg); } + +.zeitblock__name { flex: none; } +.zeitblock__zahl { + padding: 2px 9px; border-radius: 999px; + font-size: .75rem; font-weight: 700; letter-spacing: 0; + color: var(--text-leise); + background: rgba(255, 255, 255, .06); + border: 1px solid var(--rand); + font-variant-numeric: tabular-nums; +} +/* "ab 23. Sep" -- ohne das sagt "Später" nicht, ob morgen oder im + November. Steht rechts und tritt zurück. */ +.zeitblock__ab { + margin-left: auto; + font-size: .74rem; font-weight: 500; + letter-spacing: 0; text-transform: none; + color: var(--text-still); +} + +/* Der offene Block hebt sich ab -- sonst sieht man beim Scrollen nicht, + wo man gerade ist. */ +.zeitblock[open] { border-color: var(--rand-hell); } +.zeitblock[open] .zeitblock__kopf { color: var(--text-leise); } +.zeitblock .gruppe__karten { padding: 4px 13px 14px; } + +/* ZUGEKLAPPT HEISST WIRKLICH WEG (06.09.2026, gefunden von + pruef-call-kategorien). + +
versteckt seinen Inhalt von selbst -- aber nur, solange + niemand dem Inhalt eine eigene `display`-Angabe gibt. `.gruppe__karten` + traegt `display: grid` (aus dem gemeinsamen Stil der Gruppen), und + damit war der Block zwar ZUGEKLAPPT, seine Karten standen aber + weiterhin darunter: gemessen 367 Pixel Hoehe bei open=false. + + Die ganze Funktion waere damit wirkungslos gewesen -- der Knopf + haette sich bewegt und sonst nichts getan. Im Bildschirmfoto fiel es + nicht auf, weil der Ausschnitt genau darueber endete. + + Deshalb hier ausdruecklich: zu ist zu. */ +.zeitblock:not([open]) .gruppe__karten { display: none; } + +@media (prefers-reduced-motion: reduce) { + .zeitblock__kopf::before { transition: none; } +} + +@media (forced-colors: active) { + .zeitblock { border: 1px solid CanvasText; } + .zeitblock__zahl { border: 1px solid CanvasText; } +} diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css new file mode 100644 index 00000000..8e9615f2 --- /dev/null +++ b/workspace/assets/css/chat.css @@ -0,0 +1,482 @@ +/* ===================================================================== + chat.css — Gespräche im Workspace. + + Wunsch Filipe, 06.09.2026: *"richtig krass, richtig hochwertig, + professionell und ultra geil."* + + WAS DAS HIER HEISST — und was nicht: + + Nicht mehr Effekte. Ein Chat wird täglich benutzt und oft lange + angesehen; was beim ersten Blick beeindruckt, stört beim hundertsten. + Die Wirkung kommt aus vier Dingen: + + 1. EINE FLÄCHE, kein scrollendes Dokument. Die Seite hat die Höhe + des Fensters, gescrollt wird nur INNERHALB der Spalten. Das + Eingabefeld steht immer an derselben Stelle -- man muss es nie + suchen. + 2. RUHE IM VERLAUF. Fast schwarzer Grund, die eigenen Nachrichten + in einem gedeckten Blau, fremde in einem neutralen Grau. Kein + Farbverlauf in der Blase; er macht Text unruhig zu lesen. + 3. GRUPPIERUNG. Mehrere Nachrichten derselben Person rücken + zusammen und wiederholen den Namen nicht. Ein Datumstrenner + gliedert den Verlauf. + 4. GENUG LUFT. Zeilenhöhe 1.5, Blasen mit ordentlichem Innenabstand. + Enge sieht auf einem Bildschirmfoto ordentlich aus und liest + sich schlecht. + + Augenschonend ist Hausregel: keine harten Weißflächen, keine + Neonränder, Bewegung nur als kurze Blende. + ===================================================================== */ + +/* ---------- Die Fläche --------------------------------------------------- */ + +.chat-seite { + /* Der Rest des Hauses scrollt; hier soll die Seite stehen. + + 100dvh statt 100vh: Auf dem Handy ändert sich die sichtbare Höhe, + wenn die Adresszeile ein- und ausfährt -- mit vh springt das + Eingabefeld dabei aus dem Bild. + + --kopf-hoehe WIRD GEMESSEN, nicht geraten (chat.js). Der erste + Entwurf hatte hier 62 px stehen; am Handy ist die Kopfleiste aber + zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu lang, + und das Eingabefeld stand halb unterhalb des Bildschirmrands -- + ausgerechnet das Feld, um das es geht. Der Rückfallwert bleibt + stehen für den Moment vor der ersten Messung. */ + height: calc(100dvh - var(--kopf-hoehe, 62px)); + display: flex; flex-direction: column; + padding-bottom: 0; + overflow: hidden; +} + +.chat { + flex: 1; + min-height: 0; /* sonst wächst das Raster über die Fläche */ + display: grid; + grid-template-columns: minmax(240px, 320px) 1fr; + gap: 0; + border: 1px solid var(--rand); + border-radius: var(--radius); + background: + radial-gradient(120% 90% at 0% 0%, rgba(255, 255, 255, .03), transparent 55%), + var(--flaeche); + overflow: hidden; +} + +.chat__spalte { + display: flex; flex-direction: column; + min-height: 0; min-width: 0; +} +/* Der Trenner zwischen Liste und Verlauf. Läuft oben und unten aus -- + eine durchgehende Linie zerschneidet die Kachel in zwei. */ +.chat__spalte--verlauf { position: relative; } +.chat__spalte--verlauf::before { + content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 1px; + background: linear-gradient(180deg, + transparent, var(--rand-hell) 15%, var(--rand-hell) 85%, transparent); +} + +.chat__kopf { + display: flex; align-items: center; gap: 12px; + padding: 15px 17px; + border-bottom: 1px solid var(--rand); + flex: none; +} +.chat__kopf-text { min-width: 0; } +.chat__titel { + margin: 0; + font-size: 1.02rem; font-weight: 650; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat__unter { + margin: 2px 0 0; + font-size: .78rem; color: var(--text-still); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat__kopf .knopf { margin-left: auto; flex: none; } + +/* Der Zurück-Pfeil gehört dem Handy. Am Rechner stehen beide Spalten + nebeneinander, da gibt es nichts, wohin man zurückkönnte. */ +.chat__zurueck { + display: none; + flex: none; + width: 34px; height: 34px; + align-items: center; justify-content: center; + border: 1px solid var(--rand); border-radius: 10px; + background: rgba(255, 255, 255, .04); + color: var(--text-leise); cursor: pointer; +} +.chat__zurueck svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; } +.chat__zurueck:hover { color: var(--text); border-color: var(--rand-hell); } + +.chat__suche { padding: 10px 14px 4px; flex: none; } +.chat__suche input { width: 100%; } + +.chat__leer { + margin: 0; padding: 20px 18px; + font-size: .86rem; color: var(--text-still); line-height: 1.55; +} + +/* ---------- Links: die Gespräche ----------------------------------------- */ + +.chat__liste { + flex: 1; min-height: 0; + overflow-y: auto; + margin: 0; padding: 8px; list-style: none; + display: grid; gap: 3px; align-content: start; +} + +.chat-raum__knopf { + --r: var(--akzent); + width: 100%; + display: grid; grid-template-columns: auto 1fr auto; + align-items: center; gap: 11px; + padding: 10px 11px; + border: 1px solid transparent; border-radius: 12px; + background: transparent; + color: inherit; font: inherit; text-align: left; cursor: pointer; + transition: background .16s ease, border-color .16s ease; +} +.chat-raum__knopf:hover { background: rgba(255, 255, 255, .04); } +.chat-raum__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } + +/* Das offene Gespräch bleibt sichtbar markiert -- sonst weiß man nach + dem Scrollen nicht mehr, wo man ist. */ +.chat-raum[data-offen="ja"] .chat-raum__knopf { + background: color-mix(in srgb, var(--akzent) 11%, transparent); + border-color: color-mix(in srgb, var(--akzent) 32%, transparent); +} + +.chat-raum__zeichen { + flex: none; + width: 38px; height: 38px; border-radius: 50%; + display: grid; place-items: center; + font-size: .82rem; font-weight: 700; + color: var(--r); + background: color-mix(in srgb, var(--r) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--r) 30%, transparent); +} +/* Dieselben Rollenfarben wie überall im Haus. */ +.chat-raum__zeichen[data-rolle="admin"] { --r: #e8c07d; } +.chat-raum__zeichen[data-rolle="manager"] { --r: #8a76ff; } +.chat-raum__zeichen[data-rolle="scout"] { --r: #5fc99a; } +.chat-raum__zeichen[data-rolle="creator"] { --r: #3fbdf5; } +/* Eine Gruppe zeigt ihre Größe statt einer Initiale -- der Name der + Gruppe steht ohnehin daneben. */ +.chat-raum__zeichen[data-gruppe="ja"] { + --r: #a0aec4; + border-radius: 12px; + font-variant-numeric: tabular-nums; +} + +.chat-raum__mitte { min-width: 0; } +.chat-raum__oben { display: flex; align-items: baseline; gap: 8px; } +.chat-raum__name { + flex: 1; min-width: 0; + font-size: .89rem; font-weight: 600; color: var(--text-leise); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat-raum__zeit { + flex: none; + font-size: .7rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +.chat-raum__vorschau { + display: block; margin-top: 2px; + font-size: .78rem; color: var(--text-still); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat-raum__vorschau[data-weg="ja"] { font-style: italic; opacity: .8; } + +/* Ungelesen: die Zahl UND ein kräftigerer Name. Nur eine Zahl übersieht + man beim Überfliegen. */ +.chat-raum[data-neu="ja"] .chat-raum__name { color: var(--text); font-weight: 700; } +.chat-raum[data-neu="ja"] .chat-raum__vorschau { color: var(--text-leise); } +.chat-raum__zahl { + flex: none; + min-width: 21px; height: 21px; padding: 0 6px; + display: grid; place-items: center; + border-radius: 999px; + font-size: .72rem; font-weight: 700; + font-variant-numeric: tabular-nums; + color: #0b0f16; + background: var(--akzent); +} + +/* ---------- Rechts: der Verlauf ------------------------------------------ */ + +.chat__verlauf { + flex: 1; min-height: 0; + overflow-y: auto; + padding: 18px 18px 8px; + display: flex; flex-direction: column; gap: 3px; + /* Ein weicher Schleier am oberen Rand: Der Verlauf scrollt unter den + Kopf, statt an einer harten Kante abgeschnitten zu wirken. */ + mask-image: linear-gradient(180deg, transparent, #000 14px); +} + +/* KURZE GESPRÄCHE LIEGEN UNTEN AN. + + Bei drei Nachrichten standen sie oben, und darunter klaffte der halbe + Bildschirm -- man sucht die neueste dann am falschen Ende. Jeder + Messenger legt sie ans untere Ende, direkt über das Eingabefeld, und + das ist kein Geschmack: Dort schaut man hin, wenn man antworten will. + + `margin-top: auto` auf dem ERSTEN Kind statt `justify-content: + flex-end` am Behälter. Der naheliegende Weg hat einen bekannten + Haken: In einem scrollbaren Flex-Behälter wird der obere Teil damit + unerreichbar, sobald der Inhalt überläuft -- man käme an die ältesten + Nachrichten nicht mehr heran. */ +.chat__verlauf > :first-child { margin-top: auto; } + +/* Der Datumstrenner. */ +.chat-tag { + display: flex; align-items: center; gap: 10px; + margin: 14px 0 10px; + color: var(--text-still); +} +.chat-tag::before, .chat-tag::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, transparent, var(--rand-hell), transparent); +} +.chat-tag__text { + flex: none; + font-size: .72rem; font-weight: 700; + letter-spacing: .07em; text-transform: uppercase; +} + +.chat-nachricht { + display: grid; + grid-template-columns: 30px 1fr; + gap: 9px; + align-items: flex-end; + max-width: 76%; + margin-top: 9px; +} +/* Folgt eine Nachricht derselben Person, rückt sie näher und lässt den + Kreis weg -- der Platz bleibt frei, damit die Blasen fluchten. */ +.chat-nachricht[data-folge="ja"] { margin-top: 2px; } +.chat-nachricht[data-selbst="ja"] { + grid-template-columns: 1fr; + margin-left: auto; +} + +.chat-nachricht__zeichen { + --r: #a0aec4; + grid-column: 1; + width: 30px; height: 30px; border-radius: 50%; + display: grid; place-items: center; + font-size: .7rem; font-weight: 700; + color: var(--r); + background: color-mix(in srgb, var(--r) 13%, transparent); + border: 1px solid color-mix(in srgb, var(--r) 28%, transparent); +} +.chat-nachricht__zeichen[data-rolle="admin"] { --r: #e8c07d; } +.chat-nachricht__zeichen[data-rolle="manager"] { --r: #8a76ff; } +.chat-nachricht__zeichen[data-rolle="scout"] { --r: #5fc99a; } +.chat-nachricht__zeichen[data-rolle="creator"] { --r: #3fbdf5; } +/* Ohne Kreis (Folgenachricht) bleibt die Spalte trotzdem stehen. */ +.chat-nachricht[data-folge="ja"]:not([data-selbst="ja"]) .chat-nachricht__blase { grid-column: 2; } + +.chat-nachricht__blase { + grid-column: 2; + padding: 10px 13px 7px; + border: 1px solid var(--rand); + border-radius: 14px 14px 14px 4px; + background: rgba(255, 255, 255, .045); + min-width: 0; +} +.chat-nachricht[data-folge="ja"] .chat-nachricht__blase { border-radius: 14px 14px 14px 8px; } + +/* Die eigenen: gedecktes Blau, Ecke rechts. Kein Verlauf in der Fläche + -- Text auf einem Farbverlauf liest sich unruhig. */ +.chat-nachricht[data-selbst="ja"] .chat-nachricht__blase { + grid-column: 1; + border-radius: 14px 14px 4px 14px; + background: color-mix(in srgb, var(--akzent) 16%, transparent); + border-color: color-mix(in srgb, var(--akzent) 30%, transparent); +} +.chat-nachricht[data-selbst="ja"][data-folge="ja"] .chat-nachricht__blase { + border-radius: 14px 14px 8px 14px; +} + +.chat-nachricht__von { + margin: 0 0 3px; + font-size: .74rem; font-weight: 700; + color: color-mix(in srgb, var(--text-leise) 85%, transparent); +} +.chat-nachricht__text { + margin: 0; + font-size: .92rem; color: var(--text); + /* pre-wrap: Zeilenumbrüche aus der Eingabe bleiben erhalten, ohne + dass dafür HTML erzeugt werden müsste (das wäre der Weg, über den + jemand Auszeichnung einschleust). */ + white-space: pre-wrap; + overflow-wrap: anywhere; /* eine lange Adresse sprengt nichts */ + line-height: 1.5; +} +.chat-nachricht__weg { color: var(--text-still); font-style: italic; } +.chat-nachricht[data-weg="ja"] .chat-nachricht__blase { + background: transparent; + border-style: dashed; +} + +.chat-nachricht__fuss { + display: flex; align-items: center; gap: 9px; + margin-top: 4px; +} +.chat-nachricht__zeit { + font-size: .68rem; color: var(--text-still); + font-variant-numeric: tabular-nums; +} +/* Der Zurücknehmen-Knopf erscheint erst beim Darüberfahren -- er ist + die seltene Handlung und soll nicht in jeder Zeile mitlaufen. Auf + Geräten ohne Zeiger steht er immer da (siehe unten). */ +.chat-nachricht__weg-knopf { + padding: 0; border: 0; background: none; + font: inherit; font-size: .68rem; + color: var(--text-still); cursor: pointer; + opacity: 0; transition: opacity .16s ease, color .16s ease; +} +.chat-nachricht:hover .chat-nachricht__weg-knopf, +.chat-nachricht__weg-knopf:focus-visible { opacity: 1; } +.chat-nachricht__weg-knopf:hover { color: var(--warn); } + +/* Unterwegs und gescheitert -- der Unterschied muss sichtbar sein. */ +.chat-nachricht__blase:has(+ *) { /* Platzhalter, damit :has unterstützt bleibt */ } + +/* ---------- Das Eingabefeld ---------------------------------------------- */ + +.chat__eingabe { + flex: none; + display: flex; align-items: flex-end; gap: 10px; + padding: 12px 14px 14px; + border-top: 1px solid var(--rand); + background: rgba(0, 0, 0, .2); +} +.chat__eingabe-schild { + position: absolute; width: 1px; height: 1px; margin: -1px; + overflow: hidden; clip-path: inset(50%); white-space: nowrap; +} +.chat__eingabe textarea { + flex: 1; min-width: 0; + resize: none; + min-height: 42px; max-height: 160px; + line-height: 1.5; +} +.chat__senden { + flex: none; + width: 42px; height: 42px; + display: grid; place-items: center; + border: 1px solid color-mix(in srgb, var(--akzent) 40%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--akzent) 18%, transparent); + color: color-mix(in srgb, var(--akzent) 55%, #ffffff); + cursor: pointer; + transition: background .16s ease, color .16s ease; +} +.chat__senden svg { width: 20px; height: 20px; fill: currentColor; } +.chat__senden:hover { + background: color-mix(in srgb, var(--akzent) 28%, transparent); + color: #fff; +} +.chat__senden:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +/* ---------- Die Wahl im Neu-Dialog --------------------------------------- */ + +.chat-wahl { margin: 4px 0 6px; max-height: 46vh; overflow-y: auto; } +.chat-wahl__gruppe + .chat-wahl__gruppe { margin-top: 13px; } +.chat-wahl__titel { + margin: 0 0 7px; + display: flex; align-items: center; gap: 7px; + font-size: .68rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-still); +} +.chat-wahl__punkt { + width: 7px; height: 7px; border-radius: 50%; + background: var(--r, var(--text-still)); flex: none; +} +.chat-wahl__gruppe[data-rolle="admin"] { --r: #e8c07d; } +.chat-wahl__gruppe[data-rolle="manager"] { --r: #8a76ff; } +.chat-wahl__gruppe[data-rolle="scout"] { --r: #5fc99a; } +.chat-wahl__gruppe[data-rolle="creator"] { --r: #3fbdf5; } + +.chat-wahl__liste { display: flex; flex-wrap: wrap; gap: 7px; } +.chat-wahl__knopf { + --r: rgba(255, 255, 255, .5); + display: inline-flex; align-items: center; gap: 9px; + min-height: 40px; + padding: 5px 14px 5px 6px; + border: 1px solid var(--rand); border-radius: 999px; + background: rgba(255, 255, 255, .035); + color: var(--text-leise); font: inherit; cursor: pointer; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.chat-wahl__knopf[data-rolle="admin"] { --r: #e8c07d; } +.chat-wahl__knopf[data-rolle="manager"] { --r: #8a76ff; } +.chat-wahl__knopf[data-rolle="scout"] { --r: #5fc99a; } +.chat-wahl__knopf[data-rolle="creator"] { --r: #3fbdf5; } +.chat-wahl__knopf:hover { color: var(--text); border-color: rgba(255, 255, 255, .24); } +.chat-wahl__knopf:focus-visible { outline: 2px solid var(--r); outline-offset: 2px; } +.chat-wahl__zeichen { + flex: none; + width: 28px; height: 28px; border-radius: 50%; + display: grid; place-items: center; + font-size: .74rem; font-weight: 700; + color: var(--r); + background: color-mix(in srgb, var(--r) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--r) 34%, transparent); +} +.chat-wahl__name { font-size: .86rem; font-weight: 550; } +.chat-wahl__knopf[data-an="ja"] { + color: var(--text); font-weight: 600; + border-color: color-mix(in srgb, var(--r) 55%, transparent); + background: color-mix(in srgb, var(--r) 13%, transparent); +} +.chat-wahl__knopf[data-an="ja"] .chat-wahl__zeichen { + color: #12151b; background: var(--r); border-color: var(--r); +} + +/* ---------- Handy -------------------------------------------------------- + + EINE Spalte, umgeschaltet über data-ansicht. Zwei Spalten auf 390 + Pixeln hiessen zwei unbenutzbare Hälften. */ +@media (max-width: 860px) { + .chat { grid-template-columns: 1fr; } + .chat__spalte--verlauf::before { display: none; } + .chat__zurueck { display: flex; } + + .chat[data-ansicht="liste"] .chat__spalte--verlauf { display: none; } + .chat[data-ansicht="verlauf"] .chat__spalte--liste { display: none; } + + /* Mehr Breite für die Blasen -- auf einem schmalen Schirm sind 76 % + drei Wörter je Zeile. */ + .chat-nachricht { max-width: 88%; } + /* Ohne Zeiger gibt es kein "Darüberfahren": Der Knopf steht immer da, + sonst wäre er auf dem Handy gar nicht erreichbar. */ + .chat-nachricht__weg-knopf { opacity: .75; } +} + +@media (hover: none) { + .chat-nachricht__weg-knopf { opacity: .75; } +} + +@media (prefers-reduced-motion: reduce) { + .chat-raum__knopf, .chat__senden, .chat-wahl__knopf, + .chat-nachricht__weg-knopf { transition: none; } +} + +/* Ohne Farbverläufe tragen Kanten und Formen allein. Die eigenen + Nachrichten sind dann nur noch an der Ecke und der Ausrichtung zu + erkennen -- deshalb bekommen sie hier einen kräftigeren Rand. */ +@media (forced-colors: active) { + .chat { border: 1px solid CanvasText; } + .chat__spalte--verlauf::before { background: CanvasText; } + .chat-nachricht__blase { border: 1px solid CanvasText; } + .chat-nachricht[data-selbst="ja"] .chat-nachricht__blase { border-width: 2px; } + .chat-raum__zeichen, .chat-nachricht__zeichen { border: 1px solid CanvasText; } + .chat-raum__zahl { border: 1px solid CanvasText; } + .chat-tag::before, .chat-tag::after { background: CanvasText; } + .chat__verlauf { mask-image: none; } +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index e68b6c88..8862e463 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -3580,3 +3580,66 @@ body[data-fremde-sicht="ja"]::after { der Art bleibt das Merkmal für "vorbei". */ .tagesliste__punkt[data-vorbei="ja"] { opacity: 1; } } + +/* ---------- Der Chat-Knopf in der Kopfleiste ---------------------------- + + Steht auf JEDER Seite (kopf.js), nur nicht auf der Chatseite selbst. + Eine Nachricht, die man erst sieht, wenn man den Chat aufmacht, ist + keine Nachricht -- sie ist ein Fundstück. */ +.chat-knopf { + display: inline-flex; align-items: center; gap: 7px; + position: relative; + padding: 7px 13px; + border: 1px solid var(--rand); border-radius: 11px; + background: rgba(255, 255, 255, .04); + color: var(--text-leise); font-size: .85rem; font-weight: 600; + text-decoration: none; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.chat-knopf:hover { + color: var(--text); + border-color: var(--rand-hell); + background: rgba(255, 255, 255, .07); +} +.chat-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.chat-knopf__zeichen { + width: 17px; height: 17px; flex: none; + fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linejoin: round; +} + +/* Wartet etwas, trägt der Knopf selbst Farbe -- nicht nur die Plakette. + Eine kleine Zahl am Rand übersieht man beim Überfliegen. */ +.chat-knopf[data-neu="ja"] { + color: var(--akzent); + border-color: color-mix(in srgb, var(--akzent) 42%, transparent); + background: color-mix(in srgb, var(--akzent) 10%, transparent); +} +.chat-knopf__zahl { + min-width: 19px; height: 19px; padding: 0 5px; + display: grid; place-items: center; + border-radius: 999px; + /* 11,84 px -- unter 11,5 meldet die Geräteprüfung zu Recht "zu kleine + Schrift". */ + font-size: .74rem; font-weight: 700; + font-variant-numeric: tabular-nums; + color: #0b0f16; + background: var(--akzent); +} + +/* Schmale Schirme: nur das Zeichen. Der Text steht weiterhin im + aria-label. Dieselbe Grenze wie bei der Glocke -- eine eigene Zahl + daneben wäre genau die Art Wert, die beim nächsten Mal knapp + danebenliegt. */ +@media (max-width: 900px) { + .chat-knopf { padding: 7px 9px; } + .chat-knopf__text { display: none; } +} + +@media (prefers-reduced-motion: reduce) { .chat-knopf { transition: none; } } + +@media (forced-colors: active) { + .chat-knopf { border: 1px solid CanvasText; } + .chat-knopf[data-neu="ja"] { border-width: 2px; } + .chat-knopf__zahl { border: 1px solid CanvasText; } +} diff --git a/workspace/assets/js/bereiche.js b/workspace/assets/js/bereiche.js index eb8b0042..1a256e04 100644 --- a/workspace/assets/js/bereiche.js +++ b/workspace/assets/js/bereiche.js @@ -74,6 +74,12 @@ dateien: [ 'M3.5 6.5h5.5l2 2h9.5v11h-17z' ], + /* Chat (06.09.2026): eine Sprechblase mit Schweif nach links + unten. Bewusst OHNE die drei Punkte darin -- bei 20 Pixeln + werden die zu Matsch, und die Blase allein ist eindeutig. */ + chat: [ + 'M4 5.5h16v11H9.5L5 20.5V16.5H4z' + ], steckbrief: [ 'M3.5 5.5h17v13h-17z' ], @@ -276,6 +282,13 @@ rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'kalender.html', szene: 'skyline' }, { name: 'Calls & Protokolle', unter: 'Gespräche mit To-dos', zeichen: 'calls', ton: 9, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html', szene: 'lounge' }, + /* CHAT (06.09.2026). Steht neben "Calls & Protokolle", weil + beides Gespraech ist -- das eine geplant und protokolliert, + das andere zwischendurch. Ton 14 (Gruen), weil die taegliche + Gruppe sonst 3, 11, 16, 9 und 2 traegt und sich der Chat + davon abheben soll. */ + { name: 'Chat', unter: 'Nachrichten mit deinem Team', zeichen: 'chat', ton: 14, + rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'chat.html', szene: 'lounge' }, { name: 'Dateien', unter: 'Ablage und Freigaben', zeichen: 'dateien', ton: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'dateien.html', szene: 'arena' }, ], diff --git a/workspace/assets/js/calls.js b/workspace/assets/js/calls.js index 967c21e0..bf5ca964 100644 --- a/workspace/assets/js/calls.js +++ b/workspace/assets/js/calls.js @@ -403,6 +403,55 @@ { schluessel: 'erledigt', titel: 'Festgehalten', frage: null }, ]; + /* ---------- Kategorien innerhalb einer Gruppe --------------------------- + + Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27": + *"die liste da soll kategorisiert sein und werden mit einem button + zum auf und zu machen."* + + WARUM NACH ZEIT und nicht nach Titel: Auf dem Bild standen + siebenundzwanzig Karten am Stück, fast alle "Community-Talk" -- + wöchentliche Serien. Nach Titel gruppiert hätte man zwei + Überschriften und darunter wieder dieselbe lange Liste. Die Frage, + die man an eine Terminliste hat, ist "was kommt als Nächstes", und + die Antwort darauf ist ein Zeitraum. + + WAS OFFEN IST: nur die erste Kategorie, die etwas enthält. Alles + aufzuklappen wäre wieder die Wand von vorher; alles zuzuklappen + hiesse, dass man nach dem Laden erst einmal klicken muss, um + überhaupt etwas zu sehen. */ + const ZEITRAEUME = [ + { schluessel: 'heute', titel: 'Heute' }, + { schluessel: 'woche', titel: 'Diese Woche' }, + { schluessel: 'naechste', titel: 'Nächste Woche' }, + { schluessel: 'spaeter', titel: 'Später' }, + ]; + + /** In welchen Zeitraum fällt dieser Termin? + * + * ORTSZEIT durchgehend. Ein `toISOString()` läge zwischen + * Mitternacht und zwei Uhr einen Tag daneben -- und ausgerechnet + * dann wäre "Heute" leer, obwohl etwas ansteht. */ + function zeitraumVon(beginn) { + const d = new Date(String(beginn).replace(' ', 'T')); + if (Number.isNaN(d.getTime())) return 'spaeter'; + + const heute = new Date(); + heute.setHours(0, 0, 0, 0); + const tag = new Date(d); tag.setHours(0, 0, 0, 0); + const tage = Math.round((tag - heute) / 86400000); + + if (tage <= 0) return 'heute'; + /* Bis zum Ende DIESER Woche (Sonntag), nicht "in sieben Tagen". + "Diese Woche" heisst umgangssprachlich bis Sonntag -- wer am + Freitag "diese Woche" liest, erwartet nicht den Mittwoch darauf. */ + const wochentag = (heute.getDay() + 6) % 7; // Mo=0 … So=6 + const bisSonntag = 6 - wochentag; + if (tage <= bisSonntag) return 'woche'; + if (tage <= bisSonntag + 7) return 'naechste'; + return 'spaeter'; + } + function zeichne() { const ziel = $('listen'); ziel.textContent = ''; @@ -427,13 +476,73 @@ kopf.append(el('h2', 'stufe__titel', g.titel), el('span', 'stufe__zahl', String(liste.length))); s.append(kopf); if (g.frage) s.append(el('p', 'block__frage', g.frage)); - const box = el('div', 'gruppe__karten'); - for (const c of liste) box.append(karte(c, g.schluessel)); - s.append(box); + + /* DIE ANSTEHENDEN WERDEN NACH ZEITRAUM UNTERTEILT (06.09.2026). + Die anderen beiden Gruppen nicht: "Protokoll fehlt" ist eine + Arbeitsliste, die man ganz sehen will, und "Festgehalten" ist + ohnehin schon ein Archiv. Eine Unterteilung, die überall gleich + aussieht, aber nur an einer Stelle etwas nützt, verwirrt an den + anderen. */ + if (g.schluessel === 'anstehend' && liste.length > 4) { + s.append(...zeitraumBloecke(liste, g.schluessel)); + } else { + const box = el('div', 'gruppe__karten'); + for (const c of liste) box.append(karte(c, g.schluessel)); + s.append(box); + } ziel.append(s); } } + /** Baut die aufklappbaren Zeitraum-Blöcke. + * + *
und nicht selbstgebaut: bringt Tastaturbedienung, + * aria-expanded und das Verhalten der Browsersuche von sich aus mit. + * Dasselbe Bauteil wie beim Bereich "Abgebrochen" auf dem + * Aufgabenbrett -- wer eines kennt, kennt beide. */ + function zeitraumBloecke(liste, gruppe) { + const nach = new Map(ZEITRAEUME.map((z) => [z.schluessel, []])); + for (const c of liste) nach.get(zeitraumVon(c.beginn))?.push(c); + + const raus = []; + let ersterMitInhalt = true; + for (const z of ZEITRAEUME) { + const eigene = nach.get(z.schluessel); + if (!eigene.length) continue; + + const block = el('details', 'zeitblock'); + block.dataset.zeitraum = z.schluessel; + /* Nur der erste gefüllte Block ist offen -- siehe Begründung + oben bei ZEITRAEUME. */ + if (ersterMitInhalt) { block.open = true; ersterMitInhalt = false; } + + const kopf = el('summary', 'zeitblock__kopf'); + kopf.append(el('span', 'zeitblock__name', z.titel)); + kopf.append(el('span', 'zeitblock__zahl', String(eigene.length))); + /* Wann genau -- "Später" allein sagt nicht, ob morgen oder im + November. Der erste Termin des Blocks beantwortet das. */ + const erster = eigene[0]; + if (erster) { + kopf.append(el('span', 'zeitblock__ab', + z.schluessel === 'spaeter' ? `ab ${kurzDatum(erster.beginn)}` : '')); + } + block.append(kopf); + + const box = el('div', 'gruppe__karten'); + for (const c of eigene) box.append(karte(c, gruppe)); + block.append(box); + raus.push(block); + } + return raus; + } + + /** "23. Sep" -- kurz, weil es neben einer Überschrift steht. */ + function kurzDatum(beginn) { + const d = new Date(String(beginn).replace(' ', 'T')); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); + } + async function laden() { try { const a = await hole('/workspace/api/calls'); diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js new file mode 100644 index 00000000..1901865b --- /dev/null +++ b/workspace/assets/js/chat.js @@ -0,0 +1,643 @@ +/* ===================================================================== + chat.js — Gespräche im Workspace. + + Wunsch Filipe, 06.09.2026: *"jeder kann dann eine nachricht + reinschreiben, die andere person bekommt eine benachrichtigung und + kann dann antworten."* + + --------------------------------------------------------------------- + DREI ENTSCHEIDUNGEN, die den Rest erklären + + 1. NEUE NACHRICHTEN KOMMEN VON SELBST (EventSource / SSE). Kein + Nachfragen im Takt: Das wäre fünf Sekunden langsam und stellte bei + zehn Angemeldeten 7200 Anfragen in der Stunde für Nachrichten, die + es meistens nicht gibt. Reisst die Verbindung ab, verbindet der + Browser sich von selbst neu -- und wir holen die Lücke über den + normalen Verlauf nach. + + 2. DIE EIGENE NACHRICHT STEHT SOFORT DA, bevor der Server antwortet. + Ein Chat, in dem man nach dem Abschicken eine halbe Sekunde ins + Leere sieht, fühlt sich kaputt an. Kommt der Server nicht mit, + wird sie sichtbar als "nicht angekommen" markiert -- nicht still + entfernt. + + 3. GELESEN WIRD GEMELDET, wenn man wirklich hinsieht. Nicht schon + beim Laden der Seite: Wer die Seite im Hintergrund offen hat, hat + nichts gelesen, und eine Zahl, die von selbst verschwindet, ist + schlimmer als keine. + ===================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + const ROLLENKURZ = { admin: 'DogFather', manager: 'Manager', scout: 'Scout', creator: 'Creator' }; + + let ich = null; + let raeume = []; + let offen = null; // der gerade angesehene Raum + let nachrichten = []; + let strom = null; // die EventSource + let gewaehlt = new Set(); // im Neu-Dialog + + /* ---------- Kleinkram ---------------------------------------------------- */ + + async function hole(weg, einst) { + const a = await fetch(weg, { credentials: 'same-origin', ...einst }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('weg'); } + return a; + } + + /** Ein bis zwei Buchstaben für den Kreis. Dieselbe Regel wie bei der + * Teilnehmerwahl im Kalender -- Anfang UND Ende, weil "Patrick von + * Hohenstein" sonst zu "PV" würde. */ + function initialen(name) { + const teile = String(name || '').trim().split(/\s+/).filter(Boolean); + if (!teile.length) return '?'; + const erst = [...teile[0]][0] || ''; + const letzt = teile.length > 1 ? ([...teile[teile.length - 1]][0] || '') : ''; + return (erst + letzt).toLocaleUpperCase('de-DE'); + } + + /** "14:32" heute, "Gestern", sonst "3. Sep". + * + * ORTSZEIT, nicht UTC: Die Zeitstempel kommen als ISO-Text vom + * Server. `new Date(text)` liest sie richtig, `slice()` darauf wäre + * UTC und läge nachts einen Tag daneben. */ + function wann(iso, lang = false) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + if (lang) return `${d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}, ${uhr}`; + + const heute = new Date(); + const gleich = (a, b) => a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + if (gleich(d, heute)) return uhr; + const gestern = new Date(heute.getTime() - 86400000); + if (gleich(d, gestern)) return 'Gestern'; + return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); + } + + const melde = (t) => { $('fehler').textContent = t || ''; }; + + /* ---------- Die Gesprächsliste ------------------------------------------- */ + + async function raeumeLaden() { + try { + const a = await hole('/workspace/api/chat/raeume'); + if (!a.ok) return; + raeume = (await a.json()).raeume || []; + } catch { return; } + raeumeZeichnen(); + ungelesenMelden(); + } + + function raeumeZeichnen() { + const ziel = $('raeume'); + const suchtext = ($('suche').value || '').trim().toLowerCase(); + const zeigen = suchtext + ? raeume.filter((r) => r.name.toLowerCase().includes(suchtext)) + : raeume; + + ziel.textContent = ''; + $('liste-leer').hidden = raeume.length > 0; + /* Das Suchfeld erst, wenn die Liste lang genug ist, um darin zu + suchen. Vorher ist es ein Feld ohne Zweck. */ + $('suche-zeile').hidden = raeume.length < 8; + + for (const r of zeigen) { + const li = el('li', 'chat-raum'); + li.dataset.id = String(r.id); + if (offen && r.id === offen.id) li.dataset.offen = 'ja'; + if (r.ungelesen) li.dataset.neu = 'ja'; + + const knopf = el('button', 'chat-raum__knopf'); + knopf.type = 'button'; + + const zeichen = el('span', 'chat-raum__zeichen'); + zeichen.setAttribute('aria-hidden', 'true'); + if (r.art === 'gruppe') { + zeichen.dataset.gruppe = 'ja'; + zeichen.textContent = String(r.teilnehmer.length); + } else { + zeichen.textContent = initialen(r.name); + const gegen = r.teilnehmer.find((t) => t.id !== ich.id); + if (gegen) zeichen.dataset.rolle = gegen.rolle; + } + knopf.append(zeichen); + + const mitte = el('span', 'chat-raum__mitte'); + const oben = el('span', 'chat-raum__oben'); + oben.append(el('span', 'chat-raum__name', r.name)); + if (r.letzte) oben.append(el('span', 'chat-raum__zeit', wann(r.letzte.erstellt))); + mitte.append(oben); + + const vorschau = el('span', 'chat-raum__vorschau'); + if (r.letzte) { + const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : ''); + vorschau.textContent = r.letzte.zurueckgenommen + ? `${wer}Nachricht zurückgenommen` + : wer + r.letzte.text; + if (r.letzte.zurueckgenommen) vorschau.dataset.weg = 'ja'; + } else { + vorschau.textContent = 'Noch nichts geschrieben'; + vorschau.dataset.weg = 'ja'; + } + mitte.append(vorschau); + knopf.append(mitte); + + if (r.ungelesen) { + const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen)); + zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`); + knopf.append(zahl); + } + + knopf.addEventListener('click', () => raumOeffnen(r.id)); + li.append(knopf); + ziel.append(li); + } + + if (suchtext && !zeigen.length) { + const li = el('li', 'chat__leer', 'Kein Gespräch passt dazu.'); + ziel.append(li); + } + } + + /* ---------- Ein Gespräch öffnen ------------------------------------------ */ + + async function raumOeffnen(id, { sanft = false } = {}) { + try { + const a = await hole(`/workspace/api/chat/raeume/${id}/nachrichten`); + if (!a.ok) { melde('Dieses Gespräch gibt es nicht mehr.'); return; } + const daten = await a.json(); + offen = daten.raum; + nachrichten = daten.nachrichten; + } catch { return; } + + $('verlauf-titel').textContent = offen.name; + $('verlauf-unter').textContent = offen.art === 'gruppe' + ? offen.teilnehmer.map((t) => t.name).join(' · ') + : (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || ''); + $('schreiben').hidden = false; + $('verlauf-leer').hidden = true; + $('chat').dataset.ansicht = 'verlauf'; + + verlaufZeichnen(); + if (!sanft) $('text').focus(); + /* Die Adresszeile merkt sich das Gespräch -- so führt die + Benachrichtigung direkt hierher, und ein Neuladen bleibt am + richtigen Ort. */ + history.replaceState(null, '', `chat.html?raum=${id}`); + gelesenMelden(); + raeumeZeichnen(); + } + + function verlaufZeichnen() { + const ziel = $('verlauf'); + ziel.textContent = ''; + if (!nachrichten.length) { + ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.')); + return; + } + + let letzterTag = ''; + let letzterVon = null; + for (const n of nachrichten) { + /* Ein Datumstrenner, wenn ein neuer Tag beginnt. Ohne ihn steht + die Antwort von vorgestern direkt unter der von heute. */ + const tag = new Date(n.erstellt).toDateString(); + if (tag !== letzterTag) { + letzterTag = tag; + letzterVon = null; + const trenner = el('div', 'chat-tag'); + trenner.append(el('span', 'chat-tag__text', tagName(n.erstellt))); + ziel.append(trenner); + } + + const zeile = el('article', 'chat-nachricht'); + zeile.dataset.id = String(n.id); + if (n.selbst) zeile.dataset.selbst = 'ja'; + if (n.zurueckgenommen) zeile.dataset.weg = 'ja'; + /* Mehrere Nachrichten derselben Person hintereinander rücken + zusammen und wiederholen den Namen nicht -- sonst liest man + fünfmal "Patrick" und dreimal dasselbe Gesicht. */ + const gleicheFolge = letzterVon === n.von_id; + if (gleicheFolge) zeile.dataset.folge = 'ja'; + letzterVon = n.von_id; + + if (!gleicheFolge && !n.selbst) { + const z = el('span', 'chat-nachricht__zeichen', initialen(n.von)); + z.setAttribute('aria-hidden', 'true'); + z.dataset.rolle = n.rolle || ''; + zeile.append(z); + } + + const blase = el('div', 'chat-nachricht__blase'); + if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') { + blase.append(el('p', 'chat-nachricht__von', n.von)); + } + + if (n.zurueckgenommen) { + blase.append(el('p', 'chat-nachricht__text chat-nachricht__weg', + 'Nachricht zurückgenommen')); + } else { + /* textContent, nie innerHTML: Eine Nachricht darf keine + Auszeichnung einschleusen. Zeilenumbrüche macht das CSS + (white-space: pre-wrap). */ + blase.append(el('p', 'chat-nachricht__text', n.text)); + } + + const fuss = el('div', 'chat-nachricht__fuss'); + const zeit = el('time', 'chat-nachricht__zeit', wann(n.erstellt)); + zeit.dateTime = n.erstellt; + zeit.title = wann(n.erstellt, true); + fuss.append(zeit); + + if (n.selbst && !n.zurueckgenommen) { + const weg = el('button', 'chat-nachricht__weg-knopf', 'zurücknehmen'); + weg.type = 'button'; + weg.addEventListener('click', () => zuruecknehmen(n.id)); + fuss.append(weg); + } + blase.append(fuss); + zeile.append(blase); + ziel.append(zeile); + } + ziel.scrollTop = ziel.scrollHeight; + } + + function tagName(iso) { + const d = new Date(iso); + const heute = new Date(); + const gleich = (a, b) => a.toDateString() === b.toDateString(); + if (gleich(d, heute)) return 'Heute'; + if (gleich(d, new Date(heute.getTime() - 86400000))) return 'Gestern'; + return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }); + } + + /* ---------- Schreiben ---------------------------------------------------- */ + + async function abschicken(e) { + e.preventDefault(); + const feld = $('text'); + const text = feld.value.trim(); + if (!text || !offen) return; + + /* SOFORT ANZEIGEN. Die vorläufige Nachricht bekommt eine negative + Nummer -- damit lässt sie sich später zweifelsfrei durch die + echte ersetzen, ohne mit einer Server-Nummer zu kollidieren. */ + const vorlaeufig = { + id: -Date.now(), von_id: ich.id, von: ich.name, rolle: ich.rolle, + text, erstellt: new Date().toISOString(), selbst: true, unterwegs: true, + }; + nachrichten.push(vorlaeufig); + verlaufZeichnen(); + feld.value = ''; + hoeheAnpassen(); + melde(''); + + try { + const a = await hole(`/workspace/api/chat/raeume/${offen.id}/nachrichten`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!a.ok) { + const d = await a.json().catch(() => ({})); + markiereAlsGescheitert(vorlaeufig.id, d.fehler || 'Nicht angekommen.'); + return; + } + const { nachricht } = await a.json(); + const i = nachrichten.findIndex((n) => n.id === vorlaeufig.id); + if (i >= 0) nachrichten[i] = { ...nachricht, selbst: true }; + verlaufZeichnen(); + raeumeLaden(); + } catch { + markiereAlsGescheitert(vorlaeufig.id, 'Keine Verbindung.'); + } + } + + /** NICHT STILL VERSCHWINDEN LASSEN. Wer etwas schreibt und es sieht, + * glaubt, es sei angekommen. Verschwindet es wortlos, merkt er es + * im schlimmsten Fall Tage später. */ + function markiereAlsGescheitert(id, grund) { + const i = nachrichten.findIndex((n) => n.id === id); + if (i >= 0) { nachrichten[i].gescheitert = true; delete nachrichten[i].unterwegs; } + verlaufZeichnen(); + melde(`${grund} Die Nachricht steht noch da – kopiere sie und versuch es erneut.`); + } + + async function zuruecknehmen(id) { + if (!window.confirm('Diese Nachricht zurücknehmen? Der Text ist dann weg, ' + + 'im Verlauf bleibt ein Hinweis stehen.')) return; + try { + const a = await hole(`/workspace/api/chat/nachrichten/${id}`, { method: 'DELETE' }); + if (!a.ok) { melde('Ging nicht.'); return; } + const i = nachrichten.findIndex((n) => n.id === id); + if (i >= 0) { nachrichten[i].zurueckgenommen = true; nachrichten[i].text = null; } + verlaufZeichnen(); + raeumeLaden(); + } catch { /* umgeleitet */ } + } + + /** Das Feld wächst mit dem Text -- bis zu einer Grenze, sonst + * verdrängt eine lange Nachricht den ganzen Verlauf. */ + function hoeheAnpassen() { + const f = $('text'); + f.style.height = 'auto'; + f.style.height = Math.min(f.scrollHeight, 160) + 'px'; + } + + /* ---------- Gelesen ------------------------------------------------------ */ + + function gelesenMelden() { + if (!offen || !nachrichten.length) return; + /* NUR WENN MAN WIRKLICH HINSIEHT. Eine Seite im Hintergrund hat + niemand gelesen. */ + if (document.hidden) return; + const echte = nachrichten.filter((n) => n.id > 0); + if (!echte.length) return; + const bis = echte[echte.length - 1].id; + + fetch(`/workspace/api/chat/raeume/${offen.id}/gelesen`, { + method: 'PUT', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bis }), + }).then(() => { + const r = raeume.find((x) => x.id === offen.id); + if (r) { r.ungelesen = 0; raeumeZeichnen(); } + ungelesenMelden(); + }).catch(() => {}); + } + + /** Sagt der Kopfleiste, wie viele insgesamt warten. */ + async function ungelesenMelden() { + try { + const a = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' }); + if (!a.ok) return; + const { ungelesen } = await a.json(); + window.chatZahlZeigen?.(ungelesen); + } catch { /* die Zahl ist Zugabe */ } + } + + /* ---------- Neue Nachrichten kommen von selbst --------------------------- */ + + function stromOeffnen() { + if (strom) strom.close(); + strom = new EventSource('/workspace/api/chat/strom'); + + strom.addEventListener('neu', (e) => { + let n; + try { n = JSON.parse(e.data); } catch { return; } + + if (offen && n.raum_id === offen.id) { + /* SCHON DA? Zwei Wege führen hierher, und beide sind richtig: + die Antwort auf das eigene POST und der Strom. */ + if (nachrichten.some((x) => x.id === n.id)) return; + + /* DER WETTLAUF (gefunden 06.09.2026, im Zwei-Browser-Test): + Beim Abschicken steht die eigene Nachricht sofort da -- mit + einer vorläufigen negativen Nummer. Ist der Strom SCHNELLER + als die Antwort auf das POST, greift die Prüfung darüber + nicht (die Nummern sind verschieden), und die Nachricht + stand zweimal im Verlauf. + + Deshalb wird eine eigene Nachricht, die noch unterwegs ist + und denselben Text trägt, hier ERSETZT statt ergänzt. Damit + gewinnt, wer zuerst kommt, und das Ergebnis ist dasselbe. */ + if (n.von_id === ich.id) { + const i = nachrichten.findIndex( + (x) => x.unterwegs && x.text === n.text); + if (i >= 0) { + nachrichten[i] = { ...n, selbst: true }; + verlaufZeichnen(); + return; + } + } + + nachrichten.push({ ...n, selbst: n.von_id === ich.id }); + verlaufZeichnen(); + gelesenMelden(); + } + raeumeLaden(); + }); + + strom.addEventListener('weg', (e) => { + let n; + try { n = JSON.parse(e.data); } catch { return; } + const i = nachrichten.findIndex((x) => x.id === n.id); + if (i >= 0) { + nachrichten[i].zurueckgenommen = true; + nachrichten[i].text = null; + verlaufZeichnen(); + } + raeumeLaden(); + }); + + /* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen + danach den Verlauf nach -- sonst fehlte, was während der Pause + geschrieben wurde. */ + strom.addEventListener('open', () => { + if (offen) raumOeffnen(offen.id, { sanft: true }); + else raeumeLaden(); + }); + } + + /* ---------- Neues Gespräch ----------------------------------------------- */ + + async function partnerLaden() { + const ziel = $('partner'); + ziel.textContent = ''; + gewaehlt = new Set(); + $('gruppenname-zeile').hidden = true; + $('neu-los').disabled = true; + $('neu-fehler').textContent = ''; + + let personen = []; + try { + const a = await hole('/workspace/api/chat/partner'); + if (a.ok) personen = (await a.json()).personen || []; + } catch { return; } + + if (!personen.length) { + ziel.append(el('p', 'chat__leer', + 'Dir ist noch niemand zugeordnet – melde dich bei DogFather.')); + return; + } + + /* Nach Rollen gruppiert, wie bei der Teilnehmerwahl im Kalender. + Die Rolle steht einmal über der Gruppe statt an jedem Namen. */ + for (const rolle of ['admin', 'manager', 'scout', 'creator']) { + const leute = personen.filter((p) => p.rolle === rolle); + if (!leute.length) continue; + + const gruppe = el('div', 'chat-wahl__gruppe'); + gruppe.dataset.rolle = rolle; + const titel = el('p', 'chat-wahl__titel'); + const punkt = el('span', 'chat-wahl__punkt'); + punkt.setAttribute('aria-hidden', 'true'); + titel.append(punkt, document.createTextNode( + { admin: 'DogFather', manager: 'Manager', scout: 'Scouts', creator: 'Creator' }[rolle])); + gruppe.append(titel); + + const liste = el('div', 'chat-wahl__liste'); + liste.setAttribute('role', 'group'); + for (const p of leute) { + const b = el('button', 'chat-wahl__knopf'); + b.type = 'button'; + b.dataset.id = String(p.id); + b.dataset.rolle = p.rolle; + b.setAttribute('aria-pressed', 'false'); + + const z = el('span', 'chat-wahl__zeichen', initialen(p.name)); + z.setAttribute('aria-hidden', 'true'); + b.append(z, el('span', 'chat-wahl__name', p.name)); + + b.addEventListener('click', () => { + if (gewaehlt.has(p.id)) { gewaehlt.delete(p.id); delete b.dataset.an; } + else { gewaehlt.add(p.id); b.dataset.an = 'ja'; } + b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false'); + wahlGeaendert(); + }); + liste.append(b); + } + gruppe.append(liste); + ziel.append(gruppe); + } + } + + function wahlGeaendert() { + const n = gewaehlt.size; + /* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */ + $('gruppenname-zeile').hidden = n < 2; + $('neu-los').disabled = n === 0; + $('neu-los').textContent = n >= 2 ? 'Gruppe anlegen' : 'Schreiben'; + } + + async function neuAbschicken(e) { + e.preventDefault(); + const ids = [...gewaehlt]; + if (!ids.length) return; + const knopf = $('neu-los'); + knopf.disabled = true; + + try { + let raumId; + if (ids.length === 1) { + const a = await hole('/workspace/api/chat/direkt', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mit: ids[0] }), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + raumId = (await a.json()).raum_id; + } else { + const name = $('gruppenname').value.trim(); + if (name.length < 2) { + $('neu-fehler').textContent = 'Die Gruppe braucht einen Namen.'; + $('gruppenname').focus(); + return; + } + const a = await hole('/workspace/api/chat/gruppe', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, mit: ids }), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + raumId = (await a.json()).raum_id; + } + $('neu-dialog').close(); + await raeumeLaden(); + await raumOeffnen(raumId); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + } + + /* ---------- Die Höhe der Kopfleiste MESSEN, nicht raten ------------------ + + Die Chatfläche ist so hoch wie das Fenster minus die Kopfleiste. + Deren Höhe stand zuerst als fester Wert im CSS (62 px) -- am Handy + ist sie zweizeilig und gut doppelt so hoch. Folge: Die Seite war zu + lang, und das Eingabefeld stand halb unter dem Bildschirmrand. + + Ein fester Wert ist hier immer falsch: Die Leiste wächst mit der + Schriftgröße, mit der Glocke, mit einem längeren Namen. Also wird + sie gemessen -- einmal beim Start und wieder, wenn sich etwas an + der Fenstergröße ändert. */ + function kopfMessen() { + const kopf = document.querySelector('.kopfleiste'); + if (!kopf) return; + const h = Math.ceil(kopf.getBoundingClientRect().height); + document.documentElement.style.setProperty('--kopf-hoehe', `${h}px`); + } + kopfMessen(); + window.addEventListener('resize', kopfMessen); + /* Auch wenn die Leiste selbst sich ändert (Glocke erscheint, Name + wird eingesetzt) -- ohne das bliebe der erste Messwert stehen. */ + if (window.ResizeObserver) { + const kopf = document.querySelector('.kopfleiste'); + if (kopf) new ResizeObserver(kopfMessen).observe(kopf); + } + + /* ---------- Start -------------------------------------------------------- */ + + $('schreiben').addEventListener('submit', abschicken); + $('text').addEventListener('input', hoeheAnpassen); + /* Enter schickt, Umschalt+Enter macht eine neue Zeile. Am Handy + NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur, + und wer eine zweite Zeile will, käme sonst nie dazu. */ + $('text').addEventListener('keydown', (e) => { + const handy = window.matchMedia('(hover: none)').matches; + if (e.key === 'Enter' && !e.shiftKey && !handy) { + e.preventDefault(); + $('schreiben').requestSubmit(); + } + }); + + $('suche').addEventListener('input', raeumeZeichnen); + $('neu-oeffnen').addEventListener('click', async () => { + await partnerLaden(); + $('neu-dialog').showModal(); + }); + $('neu-zu').addEventListener('click', () => $('neu-dialog').close()); + $('neu-form').addEventListener('submit', neuAbschicken); + $('zur-liste').addEventListener('click', () => { + $('chat').dataset.ansicht = 'liste'; + history.replaceState(null, '', 'chat.html'); + }); + + /* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden, + dass jetzt wirklich jemand hinsieht. */ + document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + gelesenMelden(); + raeumeLaden(); + }); + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + window.werZeigen?.(ich); + + await raeumeLaden(); + stromOeffnen(); + + /* Kommt man aus einer Benachrichtigung, steht das Gespräch in der + Adresse -- dann direkt hinein. */ + const wunsch = Number(new URLSearchParams(location.search).get('raum')); + if (Number.isInteger(wunsch) && wunsch > 0) await raumOeffnen(wunsch); + })(); +})(); diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js index 2f7cef70..2fea94a7 100644 --- a/workspace/assets/js/kopf.js +++ b/workspace/assets/js/kopf.js @@ -449,6 +449,97 @@ const kopfRechts = document.querySelector('.kopfleiste__rechts'); if (kopfRechts) aufbauSuche(kopfRechts); + if (kopfRechts) aufbauChat(kopfRechts); + + /* ---------- Der Chat-Knopf mit Zähler ----------------------------------- + + Wunsch Filipe, 06.09.2026: *"die andere person bekommt eine + benachrichtigung und kann dann antworten."* + + WARUM DER ZÄHLER IN DIE KOPFLEISTE GEHÖRT und nicht nur auf die + Chatseite: Eine Nachricht, die man erst sieht, wenn man den Chat + aufmacht, ist keine Nachricht -- sie ist ein Fundstück. Die + Push-Benachrichtigung erreicht nur, wer sie erlaubt hat und wer + gerade nicht am Rechner sitzt; hier drin sieht sie jeder, auf jeder + Seite. + + Auf der Chatseite selbst wird der Knopf NICHT gebaut -- dort ist + man schon, und ein Knopf, der auf die eigene Seite führt, ist eine + Sackgasse. Die Zahl aktualisiert chat.js dann direkt. */ + function aufbauChat(wohin) { + /* Auf der Chatseite selbst gibt es nichts zu verlinken. */ + if (/\/chat\.html$/.test(location.pathname)) return; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + + const a = el('a', 'chat-knopf'); + a.href = 'chat.html'; + a.title = 'Chat'; + a.setAttribute('aria-label', 'Chat'); + + const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + blase.setAttribute('viewBox', '0 0 24 24'); + blase.setAttribute('class', 'chat-knopf__zeichen'); + blase.setAttribute('aria-hidden', 'true'); + blase.innerHTML = ''; + + const zahl = el('span', 'chat-knopf__zahl'); + zahl.hidden = true; + + a.append(blase, el('span', 'chat-knopf__text', 'Chat'), zahl); + /* Vor der Suche: Der Chat ist das, was am ehesten auf eine Antwort + wartet -- die Suche fängt man selbst an, wenn man sie braucht. */ + wohin.prepend(a); + + /** Setzt die Zahl. Wird auch von chat.js aufgerufen, damit sie dort + * sofort verschwindet, wenn man gelesen hat. */ + window.chatZahlZeigen = (n) => { + const wert = Number(n) || 0; + zahl.hidden = wert === 0; + /* Über 99 wird die Zahl breiter als der Knopf -- und die genaue + Menge interessiert ab da ohnehin niemanden mehr. */ + zahl.textContent = wert > 99 ? '99+' : String(wert); + a.dataset.neu = wert > 0 ? 'ja' : ''; + a.setAttribute('aria-label', wert + ? `Chat, ${wert} ungelesene ${wert === 1 ? 'Nachricht' : 'Nachrichten'}` + : 'Chat'); + }; + + async function holen() { + try { + const antwort = await fetch('/workspace/api/chat/ungelesen', { credentials: 'same-origin' }); + if (!antwort.ok) return; + window.chatZahlZeigen((await antwort.json()).ungelesen); + } catch { /* die Zahl ist Zugabe, kein Grund für eine Fehlermeldung */ } + } + holen(); + + /* NEUE NACHRICHTEN KOMMEN VON SELBST -- derselbe Strom wie im Chat. + Ohne ihn müsste man die Seite neu laden, um zu erfahren, dass + jemand geschrieben hat. Kein Nachfragen im Takt: Bei zehn + Angemeldeten wären das siebentausend Anfragen in der Stunde für + eine Zahl, die sich selten ändert. */ + if (window.EventSource) { + try { + const strom = new EventSource('/workspace/api/chat/strom'); + strom.addEventListener('neu', holen); + /* Nach einem Abriss verbindet der Browser selbst neu -- dann + einmal nachzählen, was inzwischen kam. */ + strom.addEventListener('open', holen); + } catch { /* dann eben nur beim Laden */ } + } + + /* Zurück an den Bildschirm: nachsehen. Fängt den Fall ab, dass der + Strom währenddessen stillgelegt wurde. */ + document.addEventListener('visibilitychange', () => { + if (!document.hidden) holen(); + }); + } function aufbauSuche(wohin) { const el = (tag, klasse, text) => { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 0a209387..8da2ece5 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -9,9 +9,9 @@ - - - + + + @@ -211,10 +211,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 82f2a4d2..962fad58 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -134,10 +134,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 619c3450..d48ed30f 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -117,14 +117,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index f5c8a9ec..fed6ff8b 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -76,11 +76,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html new file mode 100644 index 00000000..86a49167 --- /dev/null +++ b/workspace/chat.html @@ -0,0 +1,161 @@ + + + + + +Chat · Creator Workspace + + + + + + + + + + + + + + + +
+ +

Creator Workspace · Chat

+
+ … + +
+
+ +
+ + +
+ + +
+
+

Gespräche

+ +
+ + + + +
    + + +
    + + +
    + + +
    + +
    +

    Wähle ein Gespräch

    +

    +
    +
    + + +
    +

    + Links ein Gespräch auswählen – oder mit Neu eines anfangen. +

    +
    + + + + +
    +
    +
    + + + +
    +

    Neues Gespräch

    +

    + Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt + eine Gruppe. +

    + +
    + + + + + + +
    + + +
    +
    +
    + + + + + + + diff --git a/workspace/content.html b/workspace/content.html index b6115a13..d859816d 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -166,12 +166,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index afaaa540..334562b1 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -95,10 +95,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 47ae15b8..33233fff 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -27,7 +27,7 @@ - + @@ -145,6 +145,6 @@

    - + diff --git a/workspace/kalender.html b/workspace/kalender.html index ba106adc..159d7a64 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -206,10 +206,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 253d3167..b6275d3a 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -123,10 +123,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index fccb419a..45327b80 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -201,11 +201,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 91dbf2da..b613581d 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -93,11 +93,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 8e2d6362..9f40f7bb 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 82f43259..9141c2b6 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -9,8 +9,8 @@ - - + + @@ -160,10 +160,10 @@

    - - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 4deaaf27..a718faac 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -72,11 +72,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 7e70bdb2..ac03698c 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -113,10 +113,10 @@ - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index f7698b65..258c630e 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -9,9 +9,9 @@ - - - + + + @@ -59,10 +59,10 @@ - + - - - + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 37d69645..f46aa32e 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -9,10 +9,10 @@ - - - - + + + + @@ -177,10 +177,10 @@ - - - - - + + + + +