Files
dogfather-universe/server/bild-chat.mjs
T
DogFatherGitandClaude Opus 5 4637aa24e7 Chat zwischen Creator, Scouts und Managern
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
ihren manager nachrichten austauschen koennen, wie erinnerungen, fragen
und noch vieles mehr."* Auf Nachfrage entschieden: Zweier-Gespraeche UND
Gruppen; Manager und Scouts duerfen sich auch ohne gemeinsamen Creator
schreiben.

WER MIT WEM steht an EINER Stelle: schreibbareIds() in workspace.js.
Bewusst getrennt von einladbareIds() (Terminwahl): Dort geht es darum,
wen man zu einem Kalendereintrag dazustellen darf -- harmlos. Ein Chat
legt ein dauerhaftes Gespraech an, schickt eine Benachrichtigung und
macht den eigenen Namen sichtbar. Zwei Fragen, zwei Funktionen.

Ein Creator erreicht seine Betreuer und DogFather, KEINEN fremden
Creator. Eine Gruppe ist kein Schlupfloch: Jede Nummer wird einzeln
gegen dieselbe Regel geprueft.

SOFORT STATT NACHFRAGEN IM TAKT (SSE). Ein Chat, der alle fuenf
Sekunden fragt, ist fuenf Sekunden langsam und stellt bei zehn
Angemeldeten 7200 Anfragen in der Stunde fuer Nachrichten, die es
meistens nicht gibt. Kein WebSocket: Hier fliesst alles in eine
Richtung, und SSE uebersteht einen Verbindungsabriss von selbst.

DIE ZAHL STEHT AUF JEDER SEITE, nicht nur im Chat. Eine Nachricht, die
man erst sieht, wenn man den Chat aufmacht, ist keine Nachricht --
sie ist ein Fundstueck.

Push aufs Handy nur an die, die NICHT gerade zusehen. Wer die Seite
offen hat, sieht es ohnehin; ihm zusaetzlich etwas aufs Handy zu
schicken ist der schnellste Weg, dass er Benachrichtigungen abschaltet.

WAS BEWUSST NICHT GEHT:
  * Niemand liest fremde Gespraeche mit -- auch DogFather nicht. Ein
    Chat, in dem der Chef stillschweigend mitliest, ist kein Chat.
    Er kann jederzeit jedem schreiben, aber sichtbar.
  * Eine abgeschickte Nachricht laesst sich nicht aendern, nur
    zuruecknehmen. Wer Absprachen nachtraeglich umschreiben kann, macht
    den Verlauf wertlos. Zurueckgenommenes hinterlaesst einen Hinweis
    statt eines Lochs.

DAZU: CALL-LISTE NACH ZEIT GEGLIEDERT

Wunsch mit Bild von "STEHT AN 27": *"die liste soll kategorisiert sein
und werden mit einem button zum auf und zu machen."* Jetzt Heute /
Diese Woche / Naechste Woche / Spaeter, offen nur der erste gefuellte
Block. Nach ZEIT und nicht nach Titel: Auf dem Bild waren fast alle
"Community-Talk" -- nach Titel gruppiert haette man zwei Ueberschriften
und darunter dieselbe lange Liste.

DER FUND, DER DIE FUNKTION GERETTET HAT: Ein zugeklappter Block zeigte
seinen Inhalt trotzdem -- 367 Pixel Hoehe bei open=false. <details>
versteckt seinen Inhalt naemlich nur, solange niemand dem Inhalt eine
eigene display-Angabe gibt, und .gruppe__karten traegt display:grid.
Der Knopf haette sich bewegt und sonst nichts getan. Im Bildschirmfoto
fiel es nicht auf, weil der Ausschnitt genau darueber endete.

Ausserdem gefunden und behoben:
  * Ein Wettlauf beim Abschicken: War der Ereignisstrom schneller als
    die Antwort auf das POST, stand die eigene Nachricht zweimal im
    Verlauf.
  * Die Chatseite war zu hoch -- die Kopfleistenhoehe stand als fester
    Wert (62 px) im CSS, am Handy ist sie doppelt so hoch. Das
    Eingabefeld stand halb unter dem Bildschirmrand. Jetzt gemessen.
  * Der Platzhalter "Enter schickt ..." brach am Handy um UND stimmte
    dort nicht: Ohne Tastatur schickt Enter absichtlich nicht.
  * Der Dialog benutzte Klassen aus aufgaben.css, das chat.html nicht
    lud -- gemeldet von pruef-css-klassen, bevor jemand einen
    ungestylten Dialog zu sehen bekam.

Pruefungen: pruef-chat (44 Punkte, Rechte und Sichtbarkeit),
pruef-chat-optik (24, zwei echte Browser schreiben sich),
pruef-call-kategorien (16).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-06 16:28:58 +02:00

124 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);