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
privatsphaere beruecksichtig wird ueber nacht ... und die sollen nicht
anrufen koennen. nur schreiben."
Zur Auswahl standen "gar kein Chat", "nur mit Oeffnungszeiten" und
"offen wie im Team". Seine Antwort ist eine vierte, die nicht auf dem
Zettel stand, und sie ist die bessere.
1. WARUM DIE NACHTRUHE MEHR IST ALS EINE EINSTELLUNG
Sein Grund war Privatsphaere -- und zwar die der Leute, die moderieren.
Die Forschung zu ehrenamtlichen Moderatoren sagt genau das: Die beiden
meistgenannten Gruende fuers Aufhoeren sind ZU WENIG ZEIT und STREIT IM
TEAM. Ein Chat, der nachts um drei weiterlaeuft, erzeugt beides.
SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die unbequeme
Entscheidung: Der bequeme Weg waere, DogFather auszunehmen. Aber wenn er
um halb vier schreibt, liest es jemand -- und wer liest, fuehlt sich
zustaendig. Eine Nachtruhe, von der die Leitung ausgenommen ist, ist
keine, sondern eine Bitte.
MODERIEREN GEHT WEITER. Verbergen, loeschen, herausnehmen sind keine
Nachrichten. Wer nachts etwas Schlimmes sieht, kann es wegnehmen -- er
kann nur nicht darueber diskutieren. Und der vertrauliche Meldeweg
bleibt offen: Er ist ein Fall mit einer Zustaendigkeit, kein Raum mit
dreissig Leuten.
GERECHNET IN ORTSZEIT, nicht in UTC. `getHours()` auf einem UTC-Server
haette den Chat im Sommer von 2 bis 8 geschlossen -- zwei Stunden
daneben, und nur denen sichtbar, die um diese Zeit wach sind. Dieses
Haus ist mit der Rechnung schon zweimal hereingefallen.
DER RIEGEL SITZT IM SERVER, nicht im Browser. Ein ausgegrautes Feld ist
eine Hoeflichkeit; wer die Adresse kennt, spricht die Schnittstelle
direkt an. Nachrichten UND Anhaenge werden abgelehnt -- ein offener
Anhangweg waere der Umweg, den jeder findet, der es einmal versucht.
2. KEIN TELEFON -- UND WARUM DAS EINEN EIGENEN RIEGEL BRAUCHT
Ein Anruf haengt in diesem Haus an der RAUMMITGLIEDSCHAFT, nicht an der
Rolle. Solange die Community keinen Raum hatte, war die Frage muessig.
Seit sie einen hat, waere Telefonieren erlaubt -- nicht, weil es jemand
entschieden haette, sondern weil es niemand verboten hat.
Der Riegel steht ganz vorne am Anruf-Router, nicht in den neun Routen
dahinter. Alle sieben Wege sind geprueft, einzeln.
3. DER FUND, DER DIE GANZE NACHTRUHE UMGANGEN HAETTE
Ein Gast konnte ein PRIVATES ZWEIER-GESPRAECH mit DogFather aufmachen.
Die Route antwortete mit 200 und legte den Raum an.
`ohneAussen` nimmt die Community aus den Listen ANDERER heraus -- damit
kein Creator einen Zuschauer anschreibt. Die Gegenrichtung kam nie vor,
weil die Chatseite fuer 'gast' gar nicht offen war. Seit heute ist sie
offen, und damit war die Erlaubnis da.
Ein Zweier-Gespraech ist kein Kanal: keine Nachtruhe, kein Modi liest
mit, niemand koennte moderieren. Aus "die Community bekommt einen Chat"
waere ungewollt "jeder Zuschauer bekommt eine Standleitung zu
DogFather" geworden.
GEFUNDEN HAT ES DIE PRUEFUNG, NICHT DAS LESEN -- und zwar in dem Moment,
in dem ich zwei Dateien weiter selbst vor genau dieser Sorte Erlaubnis
gewarnt hatte.
4. ZWEI TOTE WEGE, EINER DAVON MEINER
pruef-community-sicht sucht nach sichtbaren Wegen, die ins Leere
fuehren. Sie fand zwei:
a) "Bei dir klingelt nichts" -> anruf-probe.html. Der Hinweis von
heute Vormittag, und die Seite steht einem Gast ausdruecklich NICHT
offen. Mein Fehler, und einer, der jedem naechsten Hinweis genauso
passiert waere.
AN DER WURZEL BEHOBEN: Die Hinweise pruefen jetzt auch die ROLLE
(`darfSeite`), nicht nur die Adresse. Das ist der 15.09. noch
einmal, eine Ebene tiefer -- damals die Adresse, diesmal die Rolle,
und dieselbe Lehre: Die Hinweise sind eine DRITTE Stelle neben
Seiten und Schnittstellen.
b) Der Verweis "Kalender" im Tagesblick. Steht fest im HTML, fuehrt
fuer die Community ins Leere -- und zwar SCHON VOR den Aenderungen
dieses Tages. Aufgefallen nur, weil (a) daneben stand. Der Weg
wird jetzt abgeleitet: Wer die Kachel hat, hat den Weg.
5. DIE PRUEFUNG LAEUFT ZWEIMAL
Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden
pruefen -- die Zeiten werden beim Laden gelesen, und ein zweiter Server
braeuchte denselben Port. pruef-treffchat ruft sich deshalb selbst noch
einmal auf, mit einem Fenster, das jetzt gilt. Dieselben Pruefungen,
andere Lage.
DIE UHR WIRD NICHT GEFAELSCHT. Verschoben wird die GRENZE, nicht die
Zeit -- eine Pruefung, die an der Systemzeit dreht, faelscht danach auch
anderes mit und ist nur nachts gruen.
6. WAS SICH NEBENBEI GEAENDERT HAT
- pruef-treff war SCHON VORHER ROT (8 Fehler): Sie erwartete sieben
Kacheln fuer die Community, es waren gestern Nacht schon zehn. Feste
Zahlen, Rechnung von gestern. Jetzt abgeleitet -- 72 Pruefungen statt
66, alle gruen.
- Ein 404 bei jedem Seitenaufruf der Community: `anruf.js` holte die
Verbindungsadressen, bevor es wusste, wer fragt. Der Browser
protokolliert das, BEVOR JavaScript abfangen kann -- also darf die
Anfrage gar nicht erst gestellt werden. `/api/ich` sagt es jetzt
vorher. Ein Fehler, der immer kommt, macht den naechsten echten
unsichtbar.
- Reaktionen (Daumen) bleiben nachts erlaubt. Ausdruecklich, mit
Begruendung im Code: Ein Daumen ist keine Nachricht, er loest keine
Meldung aus und kann keinen Streit anfangen. Eine Luecke ohne
Begruendung wird beim naechsten Durchsehen entweder geschlossen oder
uebersehen -- beides falsch.
GEMESSEN: pruef-treffchat 108/0 (neu, laeuft zweimal), pruef-treff 72/0
(vorher 66 mit 8 Fehlern), pruef-chat gruen, pruef-rechtetafel 19/0,
pruef-community-sicht gruen.
Co-Authored-By: Claude Opus 5 <[email protected]>
285 lines
12 KiB
JavaScript
285 lines
12 KiB
JavaScript
/* =====================================================================
|
||
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 = "Der Treff";
|
||
|
||
/** 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", "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
|
||
? `Der Treff 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();
|
||
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; }
|
||
}
|