Files
dogfather-universe/server/workspace-treffchat.js
T
DogFatherGitandClaude Opus 5 7a6749630c Das Rudel meint den ganzen Raum -- und die Kachelfarbe wird ein Ring
Zwei Auftraege vom 23.09.2026.

SCREEN 1: "da steht links immer noch der treff anstatt das rudel"

Und er hatte recht, auf eine Art, die niemand sehen konnte:
TREFF_NAME stand schon seit der Umbenennung auf "Das Rudel". Nur
setzt treffRaumId() den Namen ausschliesslich BEIM ANLEGEN -- der
Raum existierte laengst, also blieb die Zeile in der Datenbank auf
"Der Treff" stehen. Im Code das eine, auf dem Bildschirm das andere.

Dieselbe Falle stand direkt nebenan schon beschrieben ("drei Stellen,
von denen die dritte vergessen wird") -- die Vorsorge galt aber nur
fuer die Teilnehmer, nicht fuer den Namen. Eine halbe Selbstheilung
heilt die andere Haelfte nicht. Jetzt gleicht treffAngleichen() auch
den Namen ab; eine kuenftige Umbenennung ist wieder eine Zeile.
(IS NOT statt !=: Bei NULL ergaebe != in SQLite NULL, und die Zeile
bliebe unveraendert liegen.)

"und wenn wir da im chat @rudel machen will ich dass jeder in dem
chat markiert wird. nicht nur team ... und sehr wichtig ich rede nur
von dem chat wo die ganze community auch drin ist."

Bis heute erreichte der Ruf ueberall nur Team Dogi. Die Begruendung
stand im Code und war nicht falsch -- im Rudel-Raum sitzt die
Community. Genau das will Filipe jetzt, und zwar nur dort:

  im Rudel-Raum   -> alle, die drin sind
  ueberall sonst  -> Team Dogi, unveraendert

Was die alte Sorge entschaerft: RUFEN darf weiterhin nur Team Dogi --
kein Zuschauer kann alle wecken. Und die Nachtruhe gilt dort ohnehin.

darf_rudel folgt derselben Frage. Sonst entstuende ein stiller
Widerspruch: Ein Modi mit neun Zuschauern und ohne zweites
Teammitglied traefe mit @rudel neun Leute -- der Vorschlag beim
Tippen waere aber ausgeblendet. Die Funktion gaebe es, und niemand
faende sie.

Das Schild sagt jetzt die Wahrheit: "alle in diesem Chat" statt
"alle im Team". Stuende dort weiter das alte, waere es im Rudel-Raum
gelogen -- und zwar nach unten, also in die Richtung, in der man
leichtfertig drueckt.

Gegenprobe in pruef-erwaehnung: In der Testgruppe sitzen DogFather
und zwei Scouts; ein @rudel dort ruft NIEMANDEN. Waere die Regel
versehentlich im ganzen Haus aktiv, waeren die Scouts markiert.
"sonst nichts anfassen" ist damit gemessen, nicht versprochen.

SCREEN 2: "so eine art diagramm, wo man mit einem kreis herum gehen
kann und die farbe ganz genau selber auswaehlen kann"

Der Haken daran ist nicht die Optik. Die zwoelf Kacheln waren
GERECHNET, damit niemand sich unlesbar machen kann -- alle auf
derselben Leuchtdichte. Ein Farbkreis, der einfach den Farbton dreht,
wirft das weg: Ein gesaettigtes Gelb ist um ein Vielfaches heller als
ein gesaettigtes Blau, und helle Schrift ist darauf nicht mehr zu
lesen.

Der Ring ist deshalb KEINE neue Rechnung, sondern dieselbe in 360
Schritten statt in zwoelf. tools/chat-kacheln-rechnen.mjs --kreis
schreibt server/chat-kreis.js; buntest() und die Kontrastpruefung
darin gelten unveraendert. Ergebnis: ein Ring gleicher Helligkeit --
kein blendendes Gelb, kein abgesoffenes Blau. Das sieht ruhiger aus
als der uebliche Farbkreis, und genau das ist der Punkt.

Der Browser rechnet nichts. Er bekommt die 360 fertigen Farben und
legt sie in dieselbe Tabelle wie die Kacheln -- ab da ist "ton-214"
ein Schluessel wie "veilchen", und jede Stelle, die eine Farbe
nachschlaegt, funktioniert unveraendert.

Welche Kacheln neben dem Ring bleiben, wird ABGELEITET statt
aufgezaehlt: Grau hat keinen Farbton, Babyblau ist die eine helle mit
eigener Schrift. Nachgemessen liegen die elf bunten zwischen 0,0 und
2,4 von ihrer Ringfarbe entfernt, Grau bei 75,4 und Babyblau bei
192,9 -- zwischen 2,4 und 75 ist so viel Luft, dass die Schwelle
nicht knapp ist.

Gespeichert wird erst beim Loslassen. Wer einmal um den Ring fuehrt,
erzeugte sonst dreihundert Anfragen.

Gemessen (pruef-chat-neu, jetzt 32 Pruefungen, als Modi auf crew. mit
dem Finger): 265 px gross, aus 361 Farben gemalt, touch-action none
(sonst schiebt das Handy die Seite weg statt zu drehen), Drehen auf
drei Uhr ergibt genau Winkel 90, die Mitte zeigt exakt #675102,
vorgelesen als "Goldbraun, 90 Grad", in der Datenbank steht ton-90,
Pfeiltaste dreht ein Grad weiter. Und das eigentliche Versprechen:
alle 360 Toene tragen die Schrift der Blase, knappster Faktor 1,000.

Dabei zwei eigene Fehler, beide aufgeschrieben: Der erste Anlauf
wartete 30 Sekunden auf den Farbknopf -- am Handy weicht die Spalte
mit den Gespraechen zur Seite, sobald ein Chat offen ist. "Ist im
HTML" und "ist erreichbar" sind zwei Aussagen. Und die Schriftfarben
wurden an der falschen Klasse gemessen, was einen roten Haken an
einer heilen Stelle ergab.

Ausserdem: Das native confirm() beim Herausnehmen eines GIFs ist
weg -- meine eigene Abkuerzung vom Morgen. Gemeldet hat es
pruef-nachfrage, allerdings erst, nachdem der verlorene Backslash in
derselben Datei repariert war.

Zahlen: pruef-erwaehnung 122 (vorher 119), pruef-chat-neu 32
(vorher 19), pruef-nachfrage 46 bestanden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 14:30:02 +02:00

317 lines
14 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.
/* =====================================================================
DER TREFF-CHAT — mit Nachtruhe (19.09.2026)
Filipe, auf die Frage, ob die Community einen Chat bekommen soll:
„eine geile mischung von punkt 2 und drei. mach es perfekt so das
quasi nach mitternacht bis morgens 6 uhr keiner schreiben kann
damit auch die privatsphäre berücksichtig wird über nacht.
perfektionier das bitte. mach es übelst krass geil. und die
sollen nicht anrufen können. nur schreiben."
Zur Auswahl standen „gar kein Chat", „nur mit Öffnungszeiten" und
„offen wie im Team". Seine Antwort ist die dritte Möglichkeit, die
nicht auf dem Zettel stand, und sie ist die bessere: offen den
ganzen Tag, ZU über Nacht.
---------------------------------------------------------------------
WARUM DAS MEHR IST ALS EINE EINSTELLUNG
Der Grund, den er nennt, ist nicht Bequemlichkeit, sondern
Privatsphäre — und zwar die der Leute, die moderieren. Die Forschung
zu ehrenamtlichen Moderatoren ist an diesem Punkt eindeutig: Die
beiden meistgenannten Gründe fürs Aufhören sind ZU WENIG ZEIT und
STREIT IM TEAM. Ein Chat, der nachts um drei weiterläuft, erzeugt
beides — die Nachricht steht da, jemand fühlt sich zuständig, und am
nächsten Morgen ist aus einem Satz ein Streit geworden, den niemand
früh genug gesehen hat.
Eine Nachtruhe nimmt diesen Druck heraus, ohne irgendjemandem etwas
zu verbieten: Gelesen werden darf weiter, geschrieben ab sechs.
---------------------------------------------------------------------
SIE GILT FÜR ALLE, AUCH FÜR DIE LEITUNG
Das ist die wichtigste Entscheidung dieser Datei, und sie ist
unbequem. Der bequeme Weg wäre, DogFather und die rechte Hand
auszunehmen — sie sollen ja jederzeit eingreifen können.
Aber: Wenn DogFather um halb vier schreibt, liest es jemand. Und wer
es liest, fühlt sich zuständig. Eine Nachtruhe, von der die Leitung
ausgenommen ist, ist keine Nachtruhe, sondern eine Bitte — und
Bitten dieser Art halten genau so lange, bis es einmal eilig ist.
MODERIEREN GEHT TROTZDEM. Verbergen, löschen, jemanden
herausnehmen — das sind keine Nachrichten, sondern Eingriffe, und
die bleiben rund um die Uhr möglich. Wer nachts etwas Schlimmes
sieht, kann es wegnehmen; er kann nur nicht darüber diskutieren.
UND DER VERTRAULICHE MELDEWEG BLEIBT OFFEN. Er ist kein Chat,
sondern ein Fall mit einer Zuständigkeit. Wer nachts Hilfe braucht,
hat sie dort — nicht in einem Raum mit dreißig Leuten.
---------------------------------------------------------------------
GERECHNET WIRD IN ORTSZEIT, NICHT IN UTC
`new Date().getHours()` liefert die Stunde des SERVERS. Steht der auf
UTC, geht der Chat im Sommer um zwei Uhr nachts zu und um acht Uhr
morgens wieder auf — beides zwei Stunden daneben, und beides fällt
nur denen auf, die um diese Zeit wach sind.
Dieses Haus ist mit genau dieser Rechnung schon zweimal
hereingefallen (06.09. und 16.09., beide Male in Prüfungen, beide
Male nur nachts sichtbar). Deshalb steht hier `Intl` mit
`Europe/Berlin` und nicht `getHours()`.
---------------------------------------------------------------------
KEINE ANRUFE — UND DER RIEGEL SITZT AN EINER STELLE
„die sollen nicht anrufen können. nur schreiben."
Ein Anruf hängt in diesem Haus an der RAUMMITGLIEDSCHAFT
(`istDrin`). Sobald die Community einen Raum hat, könnte sie also
telefonieren — nicht, weil es jemand erlaubt hätte, sondern weil es
niemand verboten hat. Das ist die gefährlichste Art von Erlaubnis.
Der Riegel steht deshalb GANZ VORNE am Anruf-Router, nicht in den
neun Routen dahinter. Neun Stellen hieße acht richtige und eine
vergessene, und die vergessene wäre die, die keiner ausprobiert.
===================================================================== */
import { db, darfTelefonieren } from "./workspace.js";
import { AUSSEN_ROLLEN } from "./crew-adresse.js";
/** Die Kategorie des Treff-Kanals.
*
* ALS KANAL UND NICHT ALS EIGENE RAUMART: `chat_raeume.art` hat eine
* CHECK-Bedingung ('direkt','gruppe','kanal'). Eine vierte Art hieße,
* die Tabelle an lebenden Daten neu zu bauen — für etwas, das eine
* Kategorie genauso gut sagt. Ein Kanal ist ohnehin genau das
* Richtige: Er gehört dem Haus, nicht den Anwesenden, und die
* Leitung kommt über `istDrin` hinein, ohne dass jemand sie einträgt. */
export const TREFF_KATEGORIE = "treff";
export const TREFF_NAME = "Das Rudel";
/** Wer im Treff-Chat steht.
*
* Die Community UND die, die moderieren. Ein Raum voller Zuschauer
* ohne Moderation wäre nicht mutig, sondern fahrlässig — und die
* Nachtruhe ist kein Ersatz dafür, sie verkürzt nur die Zeit, in der
* jemand hinsehen muss. */
export const TREFF_ROLLEN = ["gast", "modi", "hand", "linke", "admin"];
/** Ab wann und bis wann niemand schreibt — Ortszeit, Stunden.
*
* 0 BIS 6, wörtlich nach Filipes Ansage („nach mitternacht bis
* morgens 6 uhr"). Als Umgebungsvariable, damit eine andere Zeit
* keine Programmänderung ist — dieselbe Bauweise wie bei der
* Push-Ruhezeit (22–7, die ist eine ANDERE Sache und bleibt getrennt:
* dort geht es darum, wann das Telefon klingelt, hier darum, wann
* überhaupt jemand schreiben kann). */
export const NACHT_AB = Number(process.env.TREFF_NACHT_AB ?? 0);
export const NACHT_BIS = Number(process.env.TREFF_NACHT_BIS ?? 6);
/** Die Stunde in Europe/Berlin — nicht die des Servers. */
const STUNDE = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin", hour: "2-digit", hour12: false,
});
export function stundeInBerlin(d = new Date()) {
/* „24" gibt es: Manche Umgebungen liefern fuer Mitternacht 24 statt
0. Ungeprueft waere der Chat dann ausgerechnet in der ersten
Stunde der Nachtruhe offen. */
const s = Number(STUNDE.format(d).replace(/\D/g, ""));
return s === 24 ? 0 : s;
}
/** Die Minute in Europe/Berlin — für den Countdown. */
const MINUTE = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin", minute: "2-digit",
});
function minuteInBerlin(d = new Date()) {
return Number(MINUTE.format(d).replace(/\D/g, ""));
}
/** Schläft der Chat gerade?
*
* ÜBER MITTERNACHT HINWEG GEDACHT. Mit AB=0 und BIS=6 ist es der
* einfache Fall (0 ≤ s < 6). Setzt jemand später AB=23, muss die
* Rechnung den Tageswechsel mitmachen — sonst wäre die Nachtruhe
* zwischen 23 und 24 Uhr aus. Beide Fälle stehen hier, damit die
* Einstellung wirklich eine Einstellung ist und keine Falle. */
export function istNachtruhe(d = new Date()) {
if (NACHT_AB === NACHT_BIS) return false; // keine Nachtruhe
const s = stundeInBerlin(d);
return NACHT_AB < NACHT_BIS
? (s >= NACHT_AB && s < NACHT_BIS)
: (s >= NACHT_AB || s < NACHT_BIS);
}
/** Wie viele Minuten noch, bis sich der Zustand ändert.
*
* Für den Countdown auf der Seite. Er ist der Unterschied zwischen
* „der Chat ist plötzlich zu" und „ich weiß seit zehn Minuten, dass
* gleich Schluss ist". */
export function minutenBisWechsel(d = new Date()) {
const s = stundeInBerlin(d), m = minuteInBerlin(d);
const jetztMin = s * 60 + m;
const ziel = (istNachtruhe(d) ? NACHT_BIS : NACHT_AB) * 60;
let diff = ziel - jetztMin;
if (diff <= 0) diff += 24 * 60;
return diff;
}
/** Alles, was die Seite über die Nachtruhe wissen muss — an einer Stelle.
*
* Die Oberfläche rechnet NICHTS davon selbst nach. Eine zweite
* Rechnung im Browser wäre die, die bei der nächsten Zeitumstellung
* anders herauskommt als die im Server — und dann sagt die Seite
* „offen", während der Server ablehnt. */
export function nachtruheStand(d = new Date()) {
const zu = istNachtruhe(d);
return {
zu,
ab: NACHT_AB,
bis: NACHT_BIS,
minuten: minutenBisWechsel(d),
/* Der fertige Satz kommt vom Server, nicht aus dem Browser. So
steht er in der Absage UND auf der Seite gleich — und wer ihn
ändern will, ändert ihn einmal. */
satz: zu
? `Das Rudel schläft. Ab ${String(NACHT_BIS).padStart(2, "0")}:00 Uhr `
+ "geht es weiter – gelesen werden darf die ganze Nacht."
: `Nachts zwischen ${String(NACHT_AB).padStart(2, "0")}:00 und `
+ `${String(NACHT_BIS).padStart(2, "0")}:00 Uhr schreibt hier niemand.`,
};
}
/** Darf diese Person überhaupt telefonieren?
*
* EINE FRAGE, EINE ANTWORT. Sie wird ganz vorne am Anruf-Router
* gestellt, nicht in den neun Routen dahinter.
*
* GERECHNET WIRD SIE IN workspace.js, weil `/api/ich` sie ebenfalls
* braucht -- und jene Datei kann nicht aus dieser importieren, ohne
* einen Ring zu bilden. Hier steht nur die Durchreichung, damit der
* Anruf-Router und der Chat sie unter demselben Namen finden. */
export { darfTelefonieren };
/** Gehört diese Person in den Treff-Chat? */
export function gehoertInTreff(person) {
return TREFF_ROLLEN.includes(person?.rolle);
}
/* ---------------------------------------------------------------------
DER RAUM
------------------------------------------------------------------- */
const jetzt = () => new Date().toISOString();
/** Die Nummer des Treff-Kanals — angelegt, falls es ihn noch nicht gibt.
*
* GENAU EINER. Zwei Räume mit demselben Zweck sind zwei Räume mit je
* der Hälfte der Nachrichten darin, und niemand merkt, in welchem er
* gerade steht. Deshalb wird gesucht, bevor angelegt wird. */
export function treffRaumId() {
const d = db();
const da = d.prepare(
"SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?")
.get(TREFF_KATEGORIE);
if (da) return da.id;
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
VALUES ('kanal', ?, ?, ?, NULL)`)
.run(TREFF_NAME, TREFF_KATEGORIE, jetzt());
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
console.log(`[treff] Chat-Raum angelegt (Nummer ${id}).`);
return id;
}
/** Trägt ein, wer hineingehört und noch fehlt.
*
* SELBSTHEILEND, wie der Kanal-Abgleich der Teamführung nebenan —
* und aus demselben Grund: Die Zeile müsste sonst beim Anlegen einer
* Person, beim Rollenwechsel und beim Anlegen des Raums entstehen.
* Drei Stellen, von denen die dritte vergessen wird.
*
* `gelesen_bis` startet am aktuellen Stand. Wer heute dazukommt, hat
* die vierhundert Nachrichten von vorher nicht verpasst — eine rote
* Vierhundert wäre keine Auskunft, sondern Rauschen.
*
* WER AUSGETRETEN IST, BLEIBT DRAUSSEN. `raus_am IS NOT NULL` heißt:
* Die Moderation hat jemanden herausgenommen. Ihn beim nächsten
* Seitenaufruf still wieder einzutragen wäre die zuverlässigste Art,
* eine Moderationsentscheidung unwirksam zu machen. */
export function treffAngleichen() {
try {
const id = treffRaumId();
const d = db();
/* DER NAME GEHÖRT DAZU — und genau das fehlte.
treffRaumId() setzt TREFF_NAME nur BEIM ANLEGEN. Als der Raum
später von „Der Treff" in „Das Rudel" umbenannt wurde, änderte
sich damit die Konstante im Code — die Zeile in der Datenbank
aber nicht. Der Raum existierte ja längst. Ergebnis: Im Code
stand „Das Rudel", in der Gesprächsliste stand weiter „Der
Treff", und zwar so lange, bis Filipe es gesehen hat
(23.09.2026: „da steht links immer noch der treff anstatt das
rudel").
Das ist dieselbe Falle wie nebenan bei den Mitgliedern, und
der Kommentar dort hat sie sogar benannt — „drei Stellen, von
denen die dritte vergessen wird". Nur galt die Vorsorge für
die Teilnehmer und nicht für den Namen. Eine halbe
Selbstheilung heilt die andere Hälfte nicht.
Jetzt wird der Name ABGELEITET statt gepflegt: Er steht an
einer Stelle (TREFF_NAME), und dieser Abgleich zieht die
Datenbank bei jedem Start hinterher. Eine künftige
Umbenennung ist damit wieder eine Zeile.
"IS NOT" statt "!=": Wäre der Name NULL, ergäbe "name != ?" in
SQLite NULL — also weder wahr noch falsch, und die Zeile bliebe
unverändert liegen. Genau der Fall, den niemand von Hand
ausprobiert. */
const umbenannt = d.prepare(
"UPDATE chat_raeume SET name = ? WHERE id = ? AND name IS NOT ?")
.run(TREFF_NAME, id, TREFF_NAME).changes;
if (umbenannt) console.log("[treff] Raum heißt jetzt " + TREFF_NAME + ".");
const platz = TREFF_ROLLEN.map(() => "?").join(", ");
const fehlen = d.prepare(`
SELECT p.id FROM personen p
WHERE p.aktiv = 1 AND p.rolle IN (${platz})
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
WHERE t.raum_id = ? AND t.person_id = p.id)`)
.all(...TREFF_ROLLEN, id);
if (!fehlen.length) return 0;
const rein = d.prepare(`
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), ?`);
const n = jetzt();
for (const p of fehlen) {
/* Leitung im Raum sind die, die auch sonst moderieren. Ein Gast
ist Teilnehmer, kein Leiter -- sonst dürfte er anheften und
andere herausnehmen. */
const person = d.prepare("SELECT rolle FROM personen WHERE id = ?").get(p.id);
const leitung = person && person.rolle !== "gast" ? 1 : 0;
rein.run(id, p.id, n, id, leitung);
}
return fehlen.length;
} catch (fehler) {
/* Ein Fehler hier darf die Gespraechsliste nicht kosten. */
console.error("[treff] Abgleich:", fehler?.message);
return 0;
}
}
/** Ist das der Treff-Raum? */
export function istTreffRaum(raumId) {
try {
const r = db().prepare(
"SELECT kategorie FROM chat_raeume WHERE id = ? AND art = 'kanal'").get(raumId);
return r?.kategorie === TREFF_KATEGORIE;
} catch { return false; }
}