diff --git a/assets/img/abzeichen-96.png b/assets/img/abzeichen-96.png
new file mode 100644
index 00000000..010d39d2
Binary files /dev/null and b/assets/img/abzeichen-96.png differ
diff --git a/server/helfer-png-alpha.mjs b/server/helfer-png-alpha.mjs
new file mode 100644
index 00000000..d5d3b258
--- /dev/null
+++ b/server/helfer-png-alpha.mjs
@@ -0,0 +1,157 @@
+/* =====================================================================
+ helfer-png-alpha.mjs — wie viel von einem PNG ist überhaupt da?
+
+ ---------------------------------------------------------------------
+ WOFÜR
+
+ Das Abzeichen einer Benachrichtigung (`badge`) wird von Android
+ AUSSCHLIESSLICH über seinen Alphakanal gelesen und einfarbig weiß
+ gemalt. Alles Bunte daran geht verloren.
+
+ Bis zum 29.09.2026 stand dort ein vollflächiges App-Symbol ohne
+ Transparenz. Dessen Alphakanal ist überall voll — in der
+ Statusleiste erschien deshalb ein ausgefüllter Klotz. Es stürzt
+ nichts ab, es sieht nur billig aus, und genau deshalb fällt so
+ etwas niemandem als Fehler auf.
+
+ Eine Prüfung, die nur nachsieht, ob die DATEI existiert, hätte den
+ Klotz nie gefunden — sie existierte ja. Gefragt ist der Inhalt:
+ Deckt das Bild 12 Prozent seiner Fläche (ein Zeichen) oder 100
+ (ein Klotz)?
+
+ ---------------------------------------------------------------------
+ WARUM SELBST GERECHNET
+
+ Im Haus gibt es keine Bildbibliothek für Node, und eine dafür zu
+ installieren wäre eine Abhängigkeit für vierzig Zeilen. PNG ist an
+ dieser Stelle einfach: Kopf lesen, die Datenblöcke
+ zusammenhängen, auspacken, die Zeilenfilter rückgängig machen.
+ `zlib` liegt in Node bei.
+
+ ---------------------------------------------------------------------
+ DREI AUSGÄNGE, NICHT ZWEI
+
+ Diese Datei kann ein PNG auch NICHT lesen können — ein anderes
+ Farbformat, 16 Bit je Kanal, ein verschachteltes Bild. Dann sagt
+ sie das (`lesbar: false, grund: "…"`) und behauptet nicht, die
+ Deckung sei null. Eine Null sähe aus wie ein Befund und wäre eine
+ Ausrede.
+ ===================================================================== */
+
+import { readFileSync } from "node:fs";
+import { inflateSync } from "node:zlib";
+
+const MAGIE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+/** Wie viel eines PNG ist sichtbar?
+ *
+ * @returns {{lesbar: boolean, grund?: string, breite?: number,
+ * hoch?: number, deckung?: number, hatAlpha?: boolean}}
+ * `deckung` ist der Anteil der Bildpunkte mit Alpha über 127.
+ */
+export function pngDeckung(pfad) {
+ let roh;
+ try {
+ roh = readFileSync(pfad);
+ } catch (f) {
+ return { lesbar: false, grund: `nicht zu öffnen: ${f?.code || f?.message}` };
+ }
+ if (roh.length < 8 || !roh.subarray(0, 8).equals(MAGIE)) {
+ return { lesbar: false, grund: "kein PNG" };
+ }
+
+ /* ---- Die Blöcke durchgehen ---- */
+ let stelle = 8;
+ let kopf = null;
+ const daten = [];
+ while (stelle + 8 <= roh.length) {
+ const laenge = roh.readUInt32BE(stelle);
+ const art = roh.toString("ascii", stelle + 4, stelle + 8);
+ const inhalt = roh.subarray(stelle + 8, stelle + 8 + laenge);
+ if (art === "IHDR") {
+ kopf = {
+ breite: inhalt.readUInt32BE(0),
+ hoch: inhalt.readUInt32BE(4),
+ tiefe: inhalt[8],
+ farbe: inhalt[9],
+ verschachtelt: inhalt[12],
+ };
+ } else if (art === "IDAT") {
+ daten.push(inhalt);
+ } else if (art === "IEND") {
+ break;
+ }
+ stelle += 12 + laenge; // Länge + Art + Inhalt + Prüfsumme
+ }
+ if (!kopf) return { lesbar: false, grund: "kein Kopf (IHDR)" };
+
+ /* ---- Was diese Datei kann, und was nicht ---- */
+ const hatAlpha = kopf.farbe === 6 || kopf.farbe === 4;
+ if (!hatAlpha) {
+ /* KEIN ALPHAKANAL heißt: das Bild ist überall voll. Das ist eine
+ Antwort und kein Unvermögen -- genau dieser Fall war der Klotz
+ in der Statusleiste. */
+ return { lesbar: true, hatAlpha: false, deckung: 1,
+ breite: kopf.breite, hoch: kopf.hoch };
+ }
+ if (kopf.tiefe !== 8) {
+ return { lesbar: false, grund: `${kopf.tiefe} Bit je Kanal, ich kann nur 8` };
+ }
+ if (kopf.verschachtelt) {
+ return { lesbar: false, grund: "verschachtelt (Adam7)" };
+ }
+ if (!daten.length) return { lesbar: false, grund: "keine Bilddaten" };
+
+ let aus;
+ try {
+ aus = inflateSync(Buffer.concat(daten));
+ } catch (f) {
+ return { lesbar: false, grund: `nicht auszupacken: ${f?.message}` };
+ }
+
+ /* ---- Die Zeilenfilter rückgängig machen ----
+ PNG speichert jede Zeile mit einem Filterbyte davor. Ohne das
+ Rückrechnen stünden dort Differenzen und keine Farbwerte -- und
+ eine Deckung, die aus Differenzen gerechnet ist, wäre eine Zahl
+ ohne Bedeutung, die trotzdem plausibel aussieht. */
+ const kanaele = kopf.farbe === 6 ? 4 : 2; // RGBA oder Grau+Alpha
+ const zeileBytes = kopf.breite * kanaele;
+ const noetig = (zeileBytes + 1) * kopf.hoch;
+ if (aus.length < noetig) {
+ return { lesbar: false, grund: `zu wenig Daten (${aus.length} statt ${noetig})` };
+ }
+
+ let voll = 0;
+ let vorige = Buffer.alloc(zeileBytes);
+ let p = 0;
+ for (let y = 0; y < kopf.hoch; y++) {
+ const filter = aus[p++];
+ const zeile = Buffer.from(aus.subarray(p, p + zeileBytes));
+ p += zeileBytes;
+ for (let i = 0; i < zeileBytes; i++) {
+ const a = i >= kanaele ? zeile[i - kanaele] : 0; // links
+ const b = vorige[i]; // oben
+ const c = i >= kanaele ? vorige[i - kanaele] : 0; // oben links
+ let w = zeile[i];
+ if (filter === 1) w += a;
+ else if (filter === 2) w += b;
+ else if (filter === 3) w += (a + b) >> 1;
+ else if (filter === 4) {
+ const s = a + b - c;
+ const da = Math.abs(s - a), dbb = Math.abs(s - b), dc = Math.abs(s - c);
+ w += (da <= dbb && da <= dc) ? a : (dbb <= dc ? b : c);
+ }
+ zeile[i] = w & 0xff;
+ }
+ for (let x = kanaele - 1; x < zeileBytes; x += kanaele) {
+ if (zeile[x] > 127) voll++;
+ }
+ vorige = zeile;
+ }
+
+ return {
+ lesbar: true, hatAlpha: true,
+ breite: kopf.breite, hoch: kopf.hoch,
+ deckung: voll / (kopf.breite * kopf.hoch),
+ };
+}
diff --git a/server/mess-reaktion.mjs b/server/mess-reaktion.mjs
index 0ff25e9f..c8da797e 100644
--- a/server/mess-reaktion.mjs
+++ b/server/mess-reaktion.mjs
@@ -393,6 +393,150 @@ if (stehen) {
ausgang = 1;
}
+/* =====================================================================
+ HAENGT DAS VIDEO? -- der Host darf sich nicht selbst anhalten
+ ===================================================================== */
+console.log("");
+console.log("=== Der Gleichlauf des Videos ===");
+{
+ /* Wie ein Pufferer aussieht, wenn er gemeldet wird: `laeuft:false`
+ bei einem Spieler, der in Wahrheit laeuft. Genau das schickt
+ `hostMelden()` im YouTube-Zustand 3. */
+ const pufferer = (seite) => seite.evaluate(async () => {
+ const stelle = document.getElementById("video-platz")?.dataset.spieler;
+ const a = await fetch("/workspace/api/reaktion/video", {
+ method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ laeuft: false, sekunde: 7 }),
+ });
+ return { vorher: stelle, code: a.status };
+ });
+
+ const stand = (seite) => seite.evaluate(
+ () => document.getElementById("video-platz")?.dataset.spieler || "(nichts)");
+
+ /* Erst sicherstellen, dass ueberhaupt etwas laeuft -- sonst misst
+ die Pruefung ein stehendes Video und nennt das Erfolg. */
+ const vorHost = await stand(host.seite);
+ const vorZu = await stand(zuschauer.seite);
+ console.log(` Vorher: Host=${vorHost} Zuschauerin=${vorZu}`);
+ const laeuftVorher = (w) => w === "laeuft" || w === "puffert";
+ if (!laeuftVorher(vorHost) || !laeuftVorher(vorZu)) {
+ console.log(" ACHTUNG: Vor der Messung laeuft schon nichts -- "
+ + "die Frage laesst sich so nicht beantworten.");
+ ausgang = 1;
+ } else {
+ const p = await pufferer(host.seite);
+ console.log(` Ein Pufferer wird gemeldet: ${p.code}`);
+ await host.seite.waitForTimeout(2000);
+ await zuschauer.seite.waitForTimeout(500);
+
+ const nachHost = await stand(host.seite);
+ const nachZu = await stand(zuschauer.seite);
+ console.log(` Danach: Host=${nachHost} Zuschauerin=${nachZu}`);
+
+ /* DER BEFUND. Der Host hat nichts angehalten -- er hat nur
+ gemeldet, dass gerade Daten fehlen. Steht sein Video danach,
+ hat sein eigenes Echo es angehalten. */
+ if (!laeuftVorher(nachHost)) {
+ console.log(" ACHTUNG: Der Host haelt sich selbst an. Ein kurzer "
+ + "Pufferer meldet 'laeuft nicht', das Echo kommt zurueck und "
+ + "drueckt Pause -- deshalb haengt das Video.");
+ ausgang = 1;
+ }
+
+ /* ---- DIE GEGENPROBE: ein ECHTES Anhalten wirkt weiter ------
+ Ohne sie waere „der Host folgt sich nicht selbst" womoeglich
+ mit einem kaputten Gleichlauf fuer alle bezahlt.
+
+ GEDRUECKT WIRD DER KNOPF, nicht eine Meldung gefaelscht. Bei
+ einem echten Anhalten meldet der Host durchgehend „steht" --
+ die Antwort haengt damit nicht am Zufall seines
+ Fuenf-Sekunden-Taktes. Die erste Fassung dieser Gegenprobe hat
+ genau daran Alarm geschlagen: Sie hielt das Selbstheilen des
+ Systems fuer einen Fehler. */
+ await host.seite.evaluate(() => document.getElementById("v-spiel")?.click());
+ await host.seite.waitForTimeout(1800);
+ const haltHost = await stand(host.seite);
+ const haltZu = await stand(zuschauer.seite);
+ console.log(` Host haelt wirklich an: Host=${haltHost} Zuschauerin=${haltZu}`);
+ if (haltHost === "laeuft" || haltZu === "laeuft") {
+ console.log(" ACHTUNG: Ein echtes Anhalten des Hosts kommt nicht an -- "
+ + "dann laeuft niemand mehr synchron.");
+ ausgang = 1;
+ }
+
+ /* Und wieder los, mit demselben Knopf. */
+ await host.seite.evaluate(() => document.getElementById("v-spiel")?.click());
+ await host.seite.waitForTimeout(2000);
+ const weiterHost = await stand(host.seite);
+ const weiterZu = await stand(zuschauer.seite);
+ console.log(` Und wieder los: Host=${weiterHost} Zuschauerin=${weiterZu}`);
+ if (!laeuftVorher(weiterHost) || !laeuftVorher(weiterZu)) {
+ console.log(" ACHTUNG: Nach dem Anhalten laeuft es nicht wieder an.");
+ ausgang = 1;
+ }
+
+ /* ---- DER KERN: was meldet der Host, WAEHREND er puffert? ----
+ Die beiden Teile darueber pruefen die Folge. Sie blieben auch
+ gruen, wenn der Host beim Puffern weiter „steht" meldete --
+ die Zuschauer ruckelten dann bei jedem Pufferer, und niemand
+ saehe es hier.
+
+ Puffern wird hergestellt: weit nach vorn springen, wohin noch
+ nichts geladen ist. Ob das GELINGT, ist nicht sicher -- auf
+ einer schnellen Leitung kann der Sprung fertig sein, bevor
+ jemand hinsieht. Deshalb der dritte Ausgang. */
+ let gesehen = null;
+ for (let i = 0; i < 8 && !gesehen; i++) {
+ await host.seite.evaluate(() => {
+ for (let n = 0; n < 12; n++) document.getElementById("v-vor")?.click();
+ });
+ for (let t = 0; t < 14 && !gesehen; t++) {
+ const w = await host.seite.evaluate(() => ({
+ spieler: document.getElementById("video-platz")?.dataset.spieler,
+ }));
+ if (w.spieler === "puffert") {
+ /* SOFORT NACHSEHEN, was der Server jetzt weiss. Eine
+ Sekunde spaeter ist der Pufferer vorbei und die Antwort
+ nichts mehr wert. */
+ gesehen = await host.seite.evaluate(async () => {
+ const a = await fetch("/workspace/api/reaktion", { credentials: "same-origin" });
+ const j = await a.json();
+ return { laeuft: j.laeuft, stand: document.getElementById("video-platz")?.dataset.spieler };
+ });
+ }
+ await host.seite.waitForTimeout(120);
+ }
+ }
+ if (!gesehen) {
+ console.log(" HINWEIS: Ein Pufferer war hier nicht herzustellen "
+ + "(schnelle Leitung) -- was der Host dabei meldet, konnte ich "
+ + "nicht nachsehen.");
+ offenePunkte.push("Was der Host beim Puffern meldet, war in diesem "
+ + "Lauf nicht messbar -- der Sprung war zu schnell geladen.");
+ } else {
+ console.log(` Beim Puffern meldet der Server: laeuft=${gesehen.laeuft} `
+ + `(Spieler: ${gesehen.stand})`);
+ if (!gesehen.laeuft) {
+ console.log(" ACHTUNG: Der Host meldet beim Puffern 'laeuft nicht'. "
+ + "Dann bekommt jeder Zuschauer bei jedem Stocken der Hostleitung "
+ + "ein Pause-Play-Paar -- das ist das Ruckeln.");
+ ausgang = 1;
+ }
+ }
+
+ /* Und wieder anlaufen lassen, damit die folgenden Messungen ein
+ laufendes Video vorfinden. */
+ await host.seite.evaluate(() => fetch("/workspace/api/reaktion/video", {
+ method: "POST", credentials: "same-origin",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ laeuft: true, sekunde: 7 }),
+ }));
+ await host.seite.waitForTimeout(1200);
+ }
+}
+
/* ---- Die Regie ---------------------------------------------------
NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst
„das Gegenteil von dem, was gerade ist" -- und setzt damit voraus,
diff --git a/server/pruef-push-ziel.mjs b/server/pruef-push-ziel.mjs
index 2292181a..a38e8572 100644
--- a/server/pruef-push-ziel.mjs
+++ b/server/pruef-push-ziel.mjs
@@ -48,6 +48,27 @@ process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+/* ---- DIE RUHEZEIT WIRD HERGESTELLT, NICHT ABGEWARTET -------------
+
+ Ab dem 29.09.2026 traegt jede Art ihre eigene Ruheregel. Um zu
+ pruefen, dass sie wirkt, muss Ruhezeit sein -- und darauf bis 22
+ Uhr zu warten waere keine Pruefung, sondern ein Kalendereintrag.
+
+ PUSH_RUHE_AB = 0 „gesperrt ab Stunde 0“ -> „immer“ ist IMMER
+ Ruhezeit, zu jeder Tageszeit.
+ PUSH_SPAET_AB = 24 „gesperrt ab Stunde 24" -- die es nicht gibt
+ -> „spaet“ ist NIE gesperrt.
+
+ Beides zusammen macht den Unterschied zwischen den Regeln
+ unabhaengig von der Uhr sichtbar. Ohne das waere diese Pruefung
+ eine Zeitbombe: gruen am Nachmittag, rot um halb drei nachts, und
+ beides ohne Aussage ueber die Software.
+
+ DIE VORHANDENEN ELF PRUEFUNGEN BLEIBEN UNBERUEHRT -- sie benutzen
+ alle die Art „test“, und die traegt `ruhe: "nie"`. */
+process.env.PUSH_RUHE_AB = "0";
+process.env.PUSH_SPAET_AB = "24";
+
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 }); };
@@ -213,6 +234,186 @@ melde("=== 4. Der Weg ===");
}
/* ------------------------------------------------------------------- */
+/* =======================================================================
+ DAS GESICHT DES HAUSES (29.09.2026)
+ =======================================================================
+
+ Im Service Worker stand fest `workspace-192.png` -- das Symbol der
+ Agentur. Gemessen am Live-Bestand gehoeren fuenf von acht Menschen
+ mit angemeldetem Geraet ins Crew-Haus; die sahen auf JEDER
+ Benachrichtigung das Symbol des Hauses, in dem sie nicht arbeiten.
+
+ Entschieden wird es auf dem Server und nicht im Browser: Dort
+ stuende sonst die verborgene Crew-Adresse in einer Datei, die ohne
+ Anmeldung abrufbar ist -- genau der Befund vom 21.09. bei
+ crew-haus.css.
+ ======================================================================= */
+melde("");
+melde("=== Das Gesicht des Hauses ===");
+{
+ const m = await push.benachrichtige(idModi, "test",
+ { titel: "x", text: "y", ziel: "/workspace/start.html" });
+ ok(m.haus === "crew", `ein Modi bekommt das Crew-Gesicht (${m.haus})`);
+
+ const g = await push.benachrichtige(idMana, "test",
+ { titel: "x", text: "y", ziel: "/workspace/start.html" });
+ ok(g.haus === "agentur", `eine Managerin das der Agentur (${g.haus})`);
+
+ /* Dass die beiden VERSCHIEDEN sind, ist die eigentliche Aussage --
+ eine Funktion, die immer „agentur“ sagt, haette die zweite Zeile
+ bestanden und die erste nicht. */
+ ok(m.haus !== g.haus, "die beiden Haeuser bekommen nicht dasselbe Gesicht");
+
+ /* DIE SEITE SCHLAEGT DIE ROLLE. DogFather arbeitet in beiden
+ Haeusern; eine Einladung ins Crew-Kino traegt trotzdem das
+ Crew-Gesicht, weil `/workspace/reaktion.html` in genau ein Haus
+ gehoert (GEHOERT_ZU_ADRESSE). */
+ const r = await push.benachrichtige(idDogi, "test",
+ { titel: "x", text: "y", ziel: "/workspace/reaktion.html" });
+ ok(r.haus === "crew",
+ `eine Einladung zur Reaction traegt das Crew-Gesicht (${r.haus})`);
+
+ /* Gegenprobe zur Zeile darueber: Dieselbe Person, ein neutrales
+ Ziel -- die Seite entscheidet also wirklich, nicht die Person
+ allein. Waere es umgekehrt, stuende hier zweimal dasselbe. */
+ const n = await push.benachrichtige(idMana, "test",
+ { titel: "x", text: "y", ziel: "/workspace/reaktion.html" });
+ ok(n.haus === "crew",
+ `auch fuer die Agentur, wenn das ZIEL im Crew-Haus liegt (${n.haus})`);
+}
+
+/* =======================================================================
+ DAS ABZEICHEN -- ein Zeichen oder ein Klotz?
+ =======================================================================
+
+ Das `badge` ist das winzige Zeichen oben in der Statusleiste. Android
+ liest davon AUSSCHLIESSLICH den Alphakanal und malt ihn einfarbig
+ weiss.
+
+ Bis zum 29.09.2026 stand dort dasselbe `workspace-192.png` wie beim
+ Symbol -- ein vollflaechiges Quadrat OHNE Alphakanal. In der
+ Statusleiste erschien deshalb ein ausgefuellter Klotz. Es stuerzt
+ nichts ab, es sieht nur billig aus, und genau deshalb faellt so
+ etwas niemandem als Fehler auf.
+
+ EINE PRUEFUNG AUF „DIE DATEI GIBT ES“ HAETTE DAS NIE GEFUNDEN -- die
+ Datei gab es ja. Gefragt ist der INHALT.
+ ======================================================================= */
+melde("");
+melde("=== Das Abzeichen ===");
+{
+ const { readFileSync } = await import("node:fs");
+ const { dirname, join: fuege } = await import("node:path");
+ const { fileURLToPath } = await import("node:url");
+ const { pngDeckung } = await import("./helfer-png-alpha.mjs");
+ const HIER = dirname(fileURLToPath(import.meta.url));
+ const REPO = fuege(HIER, "..");
+ const sw = readFileSync(fuege(REPO, "workspace", "sw.js"), "utf8");
+
+ /* ABGELESEN, NICHT ABGESCHRIEBEN: Der Pfad kommt aus dem Service
+ Worker selbst. Stuende hier der Name noch einmal, pruefte die
+ Zeile ihre eigene Kopie. */
+ const wo = /badge:\s*"([^"]+)"/.exec(sw);
+ ok(!!wo, `der Service Worker nennt ein Abzeichen (${wo?.[1] || "keins"})`);
+
+ const datei = fuege(REPO, (wo?.[1] || "").replace(/^\//, ""));
+ const e = pngDeckung(datei);
+ ok(e.lesbar, `es liegt als lesbares PNG vor (${e.grund || `${e.breite}x${e.hoch}`})`);
+ ok(e.hatAlpha === true, "es hat einen Alphakanal -- sonst ist es ein Klotz");
+ /* ZWISCHEN STAUBKORN UND KLOTZ. Unter 10 % ist in der Statusleiste
+ nichts mehr zu sehen, ueber 80 % ist es wieder die Flaeche, die
+ wir gerade abgeschafft haben. */
+ ok(e.deckung > 0.10 && e.deckung < 0.80,
+ `und deckt ${(e.deckung * 100).toFixed(0)} % seiner Flaeche (10-80 erwartet)`);
+
+ /* ---- DIE GEGENPROBE, und sie ist der alte Zustand selbst -------
+ Dieselbe Rechnung auf das App-Symbol, das bis zum 29.09. als
+ Abzeichen diente. Meldet sie dort nicht „kein Alphakanal, 100 %",
+ kann sie den Unterschied nicht sehen -- und alles darueber waere
+ wertlos. */
+ const alt = pngDeckung(fuege(REPO, "assets/img/app-symbole/workspace-192.png"));
+ ok(alt.lesbar && alt.hatAlpha === false && alt.deckung === 1,
+ `Gegenprobe: das alte Abzeichen wird als Klotz erkannt `
+ + `(Alpha ${alt.hatAlpha}, Deckung ${alt.deckung})`);
+
+ /* Und die zwei Gesichter muss es wirklich geben -- ein Symbol, das
+ 404 antwortet, zeigt Android gar nicht an. */
+ for (const n of ["crew-192.png", "workspace-192.png"]) {
+ const g = pngDeckung(fuege(REPO, "assets/img/app-symbole", n));
+ ok(g.lesbar, `${n} liegt vor (${g.grund || `${g.breite}x${g.hoch}`})`);
+ ok(sw.includes(n), `und der Service Worker nennt es`);
+ }
+}
+
+/* =======================================================================
+ DARF DAS NACHTS KOMMEN? -- drei Antworten statt zwei
+ =======================================================================
+
+ Bis zum 29.09.2026 stand die Antwort als fest verdrahtete
+ Aufzaehlung in `benachrichtige`, 230 Zeilen von der Artenliste
+ entfernt:
+
+ const dringend = art === "test" || art === "anruf";
+
+ Am 18.09. hat genau das eine Nacht lang alle Anrufe verschluckt.
+ Behoben wurde damals dieser eine Fall; die Frage dahinter blieb
+ stehen, und `dogfather_live` fiel bis heute in dieselbe Falle.
+ ======================================================================= */
+melde("");
+melde("=== Darf das nachts kommen? ===");
+{
+ /* FESTE STUNDEN ALS EINGABE, nicht als Annahme ueber „jetzt". Ein
+ Datum, das der Kalender einholt, waere eine Zeitbombe -- so wie
+ die Weckerpruefung am 07.09. */
+ const um23 = new Date(2026, 8, 29, 23, 0, 0);
+ const um3 = new Date(2026, 8, 30, 3, 0, 0);
+ const um15 = new Date(2026, 8, 29, 15, 0, 0);
+ /* Die Umgebung ist oben umgestellt; fuer die reine Rechnung wird
+ hier die Vorgabe gefragt -- die drei Regeln, nicht die Zahlen. */
+ ok(push.ruheRegel("aufgabe_faellig") === "immer",
+ "eine Erinnerung traegt „immer“");
+ ok(push.ruheRegel("anruf") === "nie", "ein Anruf traegt „nie“");
+ ok(push.ruheRegel("dogfather_live") === "spaet",
+ "„DogFather ist live“ traegt „spaet“");
+ ok(push.ruheRegel("reaktion_live") === "spaet",
+ "die Reaction-Einladung auch");
+ ok(push.ruheRegel("gibtsnicht") === "immer",
+ "eine unbekannte Art bekommt die strengste Regel");
+
+ /* Die Rechnung selbst -- mit den Grenzen dieses Laufs (ab 0 bzw.
+ nie). Dass die Vorgaben 22 und 1 sind, steht in der Artenliste;
+ hier wird geprueft, dass die REGELN verschieden wirken. */
+ ok(push.istRuhezeit(um23, "immer") === true, "um 23 Uhr ist „immer“ gesperrt");
+ ok(push.istRuhezeit(um23, "nie") === false, "„nie“ nicht");
+ ok(push.istRuhezeit(um15, "immer") === true,
+ "in diesem Lauf ist „immer“ rund um die Uhr gesperrt (hergestellt)");
+ ok(push.istRuhezeit(um3, "spaet") === false,
+ "und „spaet“ nie (hergestellt)");
+
+ /* ---- UND JETZT DAS VERHALTEN, nicht die Rechnung --------------
+ Eine Prüfung der Formel allein sagt nichts darueber, ob
+ `benachrichtige` sie auch BENUTZT. Genau diese Luecke war der
+ Fehler: Die Formel war richtig, die Aufzaehlung daneben nicht. */
+ const erinnerung = await push.benachrichtige(idHand, "aufgabe_faellig",
+ { titel: "x", text: "y", ziel: "/workspace/aufgaben.html" });
+ ok(erinnerung.grund === "ruhezeit",
+ `eine Erinnerung wird nachts zurueckgehalten (${erinnerung.grund})`);
+
+ const live = await push.benachrichtige(idHand, "dogfather_live",
+ { titel: "x", text: "y", ziel: "/workspace/unsere-seiten.html" });
+ ok(live.verschickt === 1,
+ `„DogFather ist live“ geht trotzdem raus (${live.verschickt}, ${live.grund})`);
+
+ const kino = await push.benachrichtige(idHand, "reaktion_live",
+ { titel: "x", text: "y", ziel: "/workspace/reaktion.html" });
+ ok(kino.verschickt === 1,
+ `die Einladung zur Reaction auch (${kino.verschickt}, ${kino.grund})`);
+
+ const ruf = await push.benachrichtige(idHand, "anruf",
+ { titel: "x", text: "y", ziel: "/workspace/chat.html" });
+ ok(ruf.verschickt === 1, `und ein Anruf erst recht (${ruf.verschickt})`);
+}
+
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
diff --git a/server/pruef-reaktion.mjs b/server/pruef-reaktion.mjs
index f978f45d..1d887a93 100644
--- a/server/pruef-reaktion.mjs
+++ b/server/pruef-reaktion.mjs
@@ -45,6 +45,19 @@ process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
den niemand prueft, weil das Pruefen zu lange dauert, ist ein
ungeprueftes Verhalten. */
process.env.REAKTION_LISTE_MAX = "3";
+/* DIE EINLADUNG DARF NICHT AN DER UHR SCHEITERN.
+
+ `reaktion_live` traegt die Ruheregel „spaet" -- gesperrt ist damit
+ nur zwischen 1 und 7 Uhr. Liefe diese Pruefung um halb drei nachts,
+ waere sie rot, ohne dass an der Software etwas kaputt ist: genau die
+ Zeitbombe, die in den Hausregeln steht und die am 07.09. schon die
+ Weckerpruefung getroffen hat.
+
+ `PUSH_SPAET_AB = 24` heisst: „gesperrt ab Stunde 24" -- die es nicht
+ gibt. Damit ist „spaet" hier NIE gesperrt, zu jeder Tageszeit. Die
+ Grenze selbst wird in pruef-push-ziel mit festen Stunden geprueft,
+ wo sie hingehoert: als EINGABE, nicht als Annahme ueber „jetzt". */
+process.env.PUSH_SPAET_AB = "24";
const express = (await import("express")).default;
const ec = express.response.cookie;
@@ -2424,6 +2437,128 @@ melde("=== 21. Alles zuruecksetzen ===");
});
}
+/* =======================================================================
+ DIE EINLADUNG -- geht sie raus, und an die Richtigen?
+ =======================================================================
+
+ Filipe, 29.09.2026: „dogfather und alle anderen sollen die
+ benachrichtigungen perfekt von dieser seite hier bekommen."
+
+ Bis zu diesem Tag verliess die Reaction das Haus nie. Sie rief beim
+ Einschalten `melden("reaktion", ...)`, und das laeuft ueber den
+ Ereignisstrom -- also NUR zu Leuten, die die Seite ohnehin offen
+ haben. Wer nicht hinschaut, erfuhr nichts.
+
+ GEPRUEFT WIRD DAS VERHALTEN, NICHT DER QUELLTEXT. Eine Textsuche
+ nach „benachrichtige" waere gruen gewesen, sobald das Wort irgendwo
+ steht -- auch in einem Zweig, den nichts aufruft. Hier geht die
+ Sendung wirklich auf Sendung, und danach wird nachgesehen, was in
+ `push_verschickt` steht.
+ ======================================================================= */
+melde("");
+melde("=== Die Einladung ===");
+{
+ const { createServer } = await import("node:http");
+ const { createECDH } = await import("node:crypto");
+
+ /* Ein Push-Dienst, der alles annimmt. Er muss nichts entschluesseln
+ -- gefragt ist, WER eine bekommt. Dass der Inhalt unterwegs heil
+ bleibt, beweist pruef-push-weg mit einem nachgebauten Browser. */
+ const dienstPort = await eigenerPort(import.meta, "pruef-reaktion (Push-Dienst)", 1);
+ const dienst = createServer((q, a) => {
+ q.on("data", () => {});
+ q.on("end", () => { a.writeHead(201); a.end(); });
+ });
+ await new Promise((r) => dienst.listen(dienstPort, "127.0.0.1", r));
+
+ /* Erst zu -- sonst waere der naechste Schritt kein UEBERGANG nach
+ live, und eingeladen wird nur beim Uebergang. */
+ await roh(CREW, "/workspace/api/reaktion/stand",
+ { method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
+
+ const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ /* Je ein Geraet fuer JEDEN -- auch fuer die, die keine Einladung
+ bekommen sollen. Ohne ihr Geraet waere „hat nichts bekommen" die
+ Antwort auf die falsche Frage: Man wuesste nicht, ob die Auswahl
+ greift oder ob nur kein Telefon da war. */
+ for (const rolle of ["admin", "hand", "linke", "modi", "gast", "gast2", "creator"]) {
+ const kk = createECDH("prime256v1");
+ kk.generateKeys();
+ db2.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
+ VALUES (?,?,?,?,?)`)
+ .run(IDS[rolle], `http://127.0.0.1:${dienstPort}/push/${rolle}`,
+ kk.getPublicKey().toString("base64url"),
+ randomBytes(16).toString("base64url"), nun);
+ }
+ db2.close();
+
+ const anSendung = await roh(CREW, "/workspace/api/reaktion/stand",
+ { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
+ ok(anSendung.code === 200, `auf Sendung (${anSendung.code})`);
+
+ /* Die Einladung wird losgeschickt und nicht abgewartet -- der Knopf
+ „Auf Sendung" soll sofort reagieren. Also hier warten, wo Warten
+ nichts kostet. */
+ await new Promise((r) => setTimeout(r, 2500));
+
+ const lies = () => {
+ const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const z = db3.prepare(
+ `SELECT person_id FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).all();
+ db3.close();
+ return new Set(z.map((x) => x.person_id));
+ };
+ const bekommen = lies();
+
+ const sollen = [["hand", "die rechte Hand"], ["linke", "die linke Hand"],
+ ["modi", "ein Modi"], ["gast", "die Community"], ["gast2", "noch jemand aus der Community"]];
+ for (const [rolle, wort] of sollen) {
+ ok(bekommen.has(IDS[rolle]), `${wort} wird eingeladen`);
+ }
+
+ /* WER EINGESCHALTET HAT, BEKOMMT KEINE. Er hat gerade den Knopf
+ gedrueckt; eine Einladung zu etwas, das man selbst aufgemacht
+ hat, ist Rauschen. */
+ ok(!bekommen.has(IDS.admin),
+ "wer eingeschaltet hat, bekommt keine Einladung");
+
+ /* DAS ANDERE HAUS BLEIBT AUSSEN VOR. Fuer eine Creatorin gibt es
+ `reaktion.html` nicht -- sie bekaeme eine Meldung, die sie auf
+ ihre Startseite wirft. Eine Benachrichtigung, die nirgendwohin
+ fuehrt, ist schlimmer als keine. */
+ ok(!bekommen.has(IDS.creator),
+ "die Agentur bekommt keine Einladung ins Crew-Kino");
+
+ ok(bekommen.size === 5,
+ `genau fuenf Einladungen, nicht mehr (${bekommen.size})`);
+
+ /* ---- Die Bremse gegen den Neustart ---------------------------
+ Wer aus Versehen auf „Vorbereitung" und gleich wieder auf „Auf
+ Sendung" drueckt, laedt sonst zweimal ein. Fuenf Menschen
+ bekaemen zwei Meldungen fuer dieselbe Sendung -- der schnellste
+ Weg, dass jemand die Art abschaltet, und dann fehlt auch die
+ naechste. */
+ const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const vorher = db4.prepare(
+ `SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n;
+ db4.close();
+
+ await roh(CREW, "/workspace/api/reaktion/stand",
+ { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
+ await roh(CREW, "/workspace/api/reaktion/stand",
+ { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
+ await new Promise((r) => setTimeout(r, 2000));
+
+ const db5 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const nachher = db5.prepare(
+ `SELECT COUNT(*) AS n FROM push_verschickt WHERE merkmal LIKE 'reaktion_live:%'`).get().n;
+ db5.close();
+ ok(nachher === vorher,
+ `ein Neustart laedt nicht noch einmal ein (${vorher} -> ${nachher})`);
+
+ await new Promise((r) => dienst.close(r));
+}
+
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
diff --git a/server/workspace-push.js b/server/workspace-push.js
index c64388d4..7b906240 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -58,6 +58,10 @@ import {
gehoertAufDieseAdresse, TEAM_DOGI_ROLLEN,
} from "./workspace.js";
import { schluesselErzeugen, schicken } from "./workspace-push-krypto.js";
+/* Die Hausteilung kommt aus der Datei, in der sie steht -- nicht aus
+ einer zweiten Fassung hier. `crew-adresse.js` haengt von nichts ab
+ (nur von node:fs/path), ein Kreis entsteht also nicht. */
+import { AGENTUR_ROLLEN, GEHOERT_ZU_ADRESSE, istCrewAdresse } from "./crew-adresse.js";
import { rhythmus as befindenRhythmus } from "./workspace-befinden.js";
import { SEITEN } from "./rechte.js";
@@ -70,6 +74,41 @@ const jetzt = () => new Date().toISOString();
eine bewusste Auswahl, keine Nutzdaten. Wer eine neue Art braucht,
traegt sie hier ein und sie erscheint von selbst in den
Einstellungen.
+
+ ---------------------------------------------------------------------
+ `ruhe` -- DARF DAS NACHTS KOMMEN? (29.09.2026)
+
+ Bis hierher stand das als fest verdrahtete Aufzaehlung 230 Zeilen
+ weiter unten in `benachrichtige`:
+
+ const dringend = art === "test" || art === "anruf";
+
+ Wer eine neue Art eintrug, sah sie nicht -- und diese Liste sagte
+ ueber die Ruhezeit kein Wort, obwohl sie darueber entscheidet, ob
+ eine Meldung jemals ankommt. Am 18.09. hat genau das eine Nacht
+ lang alle Anrufe verschluckt; damals wurde EINE Art ausgenommen,
+ statt die Frage zu stellen.
+
+ DREI ANTWORTEN, WEIL ES DREI FAELLE GIBT:
+
+ "immer" (Vorgabe) 22-7 gesperrt.
+ Erinnerungen. Der Server schickt sie von sich aus, weil
+ eine Frist naeher rueckt -- die kann bis morgen warten.
+
+ "spaet" nur 1-7 gesperrt.
+ Etwas LAEUFT GERADE und ist in zwei Stunden vorbei. Eine
+ Einladung um 22:30 will man haben; um vier Uhr nachts
+ ist die Sendung laengst aus, und die Meldung waere nur
+ noch eine Stoerung.
+
+ "nie" immer durch.
+ Ein Mensch drueckt gerade auf den Hoerer und wartet.
+ Ein Telefon, das nachts stumm bleibt, ist kein Telefon.
+
+ GEMESSEN, WARUM "spaet" gebraucht wird: An allen sieben Abenden vom
+ 22. bis 28.09.2026 ging der Live-Ruf zwischen 20:28 und 21:06 raus.
+ Jedes Mal knapp vor der Sperre. Geht Filipe einmal um 22:05 live,
+ bekommt niemand etwas -- und niemand erfaehrt, warum.
------------------------------------------------------------------ */
export const ARTEN = [
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true },
@@ -166,7 +205,41 @@ export const ARTEN = [
Eine eigene Art und nicht "termin_gleich": Das eine ist ein
Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das
eine stumm stellt, meint selten das andere. */
- { schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true },
+ { schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true,
+ /* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um
+ 22:05 interessant. Mit der alten Regel waere er stumm geblieben,
+ und die Meldung, die Filipe ausdruecklich bestellt hat („die
+ leute sollen auch automatisch eine benarichtigung bekommen"),
+ haette an genau den Abenden nicht funktioniert, an denen es
+ spaeter wurde. */
+ ruhe: "spaet" },
+
+ /* ---- DIE REACTION LAEDT EIN (29.09.2026) ------------------------
+
+ Filipe: „ich will dass du die benarichtigungen perfektionierst.
+ dogfather und alle anderen sollen die benachrichtigungen perfekt
+ von dieser seite hier bekommen."
+
+ NACHGEMESSEN WAR DIE REACTION STUMM. `workspace-reaktion.js`
+ enthielt kein einziges `benachrichtige`. Geht die Sendung auf
+ Sendung, ruft sie `melden("reaktion", ...)` -- und das geht ueber
+ den Ereignisstrom, also NUR an Leute, die die Seite ohnehin schon
+ offen haben. Das Kino macht auf, und die Einladung verlaesst das
+ Haus nie.
+
+ Dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege
+ gebaut, beide stumm.
+
+ EIGENE ART UND NICHT `dogfather_live`. Das eine ist sein Stream
+ auf TikTok (gemessen ueber `liveStatusHolen` beim Postfach), das
+ andere das Kino hier im Haus. Wer abends nicht auf TikTok geht,
+ aber zur Reaction kommt, will genau das eine und nicht das
+ andere -- und umgekehrt.
+
+ `ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um
+ 22:30 ist der Normalfall, keine Ausnahme. */
+ { schluessel: "reaktion_live",
+ name: "Die Reaction geht auf Sendung", vorgabe: true, ruhe: "spaet" },
/* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------
Filipe: „sie bekommt nur eine benarichtigung das eine neue
@@ -191,7 +264,7 @@ export const ARTEN = [
ist kein Telefon. Wer nachts nicht gestoert werden will, schaltet
jetzt diese Art ab -- eine Entscheidung, die man selbst trifft,
statt einer Regel, die man nicht kennt. */
- { schluessel: "anruf", name: "Jemand ruft an", vorgabe: true },
+ { schluessel: "anruf", name: "Jemand ruft an", vorgabe: true, ruhe: "nie" },
/* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung
einmal am tag zu aktivieren wenn noch aufgaben auf sind."
@@ -232,7 +305,9 @@ export const ARTEN = [
IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint
auf einem Sperrbildschirm, den auch andere sehen. */
{ schluessel: "hilfe", name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
- { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true },
+ /* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die
+ stellt man sich auch um 23 Uhr. */
+ { schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" },
];
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
@@ -274,8 +349,32 @@ export function vorlauf(minuten) {
eine Umgebungsvariable und kein Programmierauftrag. */
const RUHE_AB = Number(process.env.PUSH_RUHE_AB ?? 22);
const RUHE_BIS = Number(process.env.PUSH_RUHE_BIS ?? 7);
-export function istRuhezeit(d = new Date()) {
+/* Wann eine „spaet"-Meldung aufhoert. Ein Uhr und nicht zwei: Was um
+ ein Uhr anfaengt, ist um drei vorbei -- eine Einladung dazu erreicht
+ niemanden mehr, sie weckt nur. */
+const SPAET_AB = Number(process.env.PUSH_SPAET_AB ?? 1);
+
+/** Welche Ruheregel gilt fuer diese Art?
+ *
+ * ABGELEITET AUS DER ARTENLISTE, nicht danebengeschrieben. Eine
+ * zweite Aufzaehlung waere genau die, die beim naechsten Eintrag
+ * vergessen wird -- so ist es am 18.09. beim Anruf gewesen. */
+export function ruheRegel(art) {
+ return ARTEN.find((a) => a.schluessel === art)?.ruhe || "immer";
+}
+
+/** Ist gerade Ruhezeit -- fuer diese Regel?
+ *
+ * `regel` steht hinten und hat eine Vorgabe, damit vorhandene
+ * Aufrufer (und die Pruefungen) unveraendert weiterlaufen. */
+export function istRuhezeit(d = new Date(), regel = "immer") {
+ if (regel === "nie") return false;
const s = d.getHours();
+ /* „spaet" ist EIN Bereich und nicht zwei -- SPAET_AB liegt vor
+ RUHE_BIS. Die Rechnung von „immer" (zwei Bereiche um Mitternacht
+ herum) gilt hier also nicht, und sie hier mitzubenutzen waere ein
+ Fehler, den man erst um ein Uhr nachts sieht. */
+ if (regel === "spaet") return s >= SPAET_AB && s < RUHE_BIS;
return s >= RUHE_AB || s < RUHE_BIS;
}
@@ -420,25 +519,67 @@ function zielFuer(personId, ziel) {
}
}
+/** Welches Haus traegt diese Meldung? -> "crew" oder "agentur"
+ *
+ * Nur fuer das GESICHT der Benachrichtigung (Symbol und Abzeichen).
+ * Ueber Rechte entscheidet das hier nichts -- das tut `zielFuer` eine
+ * Funktion weiter oben, und zwar unveraendert.
+ *
+ * ZWEISTUFIG, UND BEIDE STUFEN GIBT ES SCHON:
+ *
+ * 1. Die Zielseite, wenn sie in genau ein Haus gehoert. Damit
+ * traegt die Einladung zur Reaction das Crew-Gesicht -- auch
+ * fuer DogFather, der in beiden Haeusern arbeitet und sonst
+ * ueberall dasselbe Symbol saehe.
+ *
+ * 2. Sonst das Haus der Person. `AGENTUR_ROLLEN` ist die
+ * vorhandene Menge; wer nicht darin steht, arbeitet im
+ * Crew-Haus. Wortgleich zu `nurAnderesHaus()`.
+ *
+ * IM ZWEIFEL "agentur" -- das ist der Stand von vor dem 29.09.2026.
+ * Ein Fehler hier darf das Gesicht aendern, nie die Zustellung. */
+export function hausFuerMeldung(personId, ziel) {
+ try {
+ const pfad = String(ziel || "").split("?")[0].split("#")[0];
+ const eintrag = GEHOERT_ZU_ADRESSE.find((e) => e.datei === pfad);
+ if (eintrag) return eintrag.zuhause === istCrewAdresse ? "crew" : "agentur";
+ const p = db().prepare("SELECT rolle FROM personen WHERE id = ?").get(personId);
+ if (!p) return "agentur";
+ return AGENTUR_ROLLEN.has(p.rolle) ? "agentur" : "crew";
+ } catch (f) {
+ console.error("[push] Haus bestimmen:", f?.message);
+ return "agentur";
+ }
+}
+
export async function benachrichtige(personId, art, { titel, text, ziel, merkmal }) {
if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" };
if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" };
- /* Die Probe darf immer durch -- sie beantwortet die Frage "kommt
- ueberhaupt etwas an?", und die stellt man sich auch um 23 Uhr.
+ /* WELCHE RUHEREGEL GILT, STEHT AN DER ART (29.09.2026). Hier stand
+ bis dahin `art === "test" || art === "anruf"` -- eine Aufzaehlung
+ 230 Zeilen von der Artenliste entfernt, die niemand sieht, der
+ eine Art eintraegt.
- UND EIN ANRUF AUCH (18.09.2026). Die Ruhezeit ist gegen
- Erinnerungen gebaut: Dinge, die der Server von sich aus schickt,
- weil eine Frist naeher rueckt. Ein Anruf ist das Gegenteil -- ein
- Mensch drueckt gerade auf den Hoerer und wartet.
-
- Gefunden hat das Filipes Satz „sie bekommt nur eine
+ Gefunden hat den Fall Filipes Satz „sie bekommt nur eine
benarichtigung ... irgendwas laeuft da gewaltig schief", um 23:44
Uhr. Serverseitig war alles in Ordnung; der Anruf wurde nur nie
- ausgeliefert, weil es nach 22 Uhr war. Wer nachts seine Ruhe will,
- schaltet die Art „Jemand ruft an" ab. */
- const dringend = art === "test" || art === "anruf";
- if (!dringend && istRuhezeit()) return { verschickt: 0, grund: "ruhezeit" };
+ ausgeliefert, weil es nach 22 Uhr war. Behoben wurde damals genau
+ dieser eine Fall -- die Frage dahinter blieb stehen, und
+ `dogfather_live` faellt bis heute in dieselbe Falle.
+
+ WER NACHTS SEINE RUHE WILL, schaltet die Art ab. Das ist eine
+ Entscheidung, die man selbst trifft, statt einer Regel, die man
+ nicht kennt. */
+ const regel = ruheRegel(art);
+ if (istRuhezeit(new Date(), regel)) return { verschickt: 0, grund: "ruhezeit" };
+
+ /* DRAENGELN IST ETWAS ANDERES ALS NACHTS STOEREN. Der Push-Dienst
+ kennt eine Dringlichkeit, die darueber entscheidet, ob ein
+ schlafendes Geraet dafuer aufwacht. Das gilt fuer den Anruf und
+ die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn
+ das Geraet ohnehin wach wird, und keinen Akku dafuer verbrennen. */
+ const dringend = regel === "nie";
/* Schon einmal geschickt? */
if (merkmal) {
@@ -456,7 +597,13 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
nachsehen kann, wohin wirklich geschickt wurde, ohne die
Nachricht aufmachen zu muessen. */
const zielEcht = zielFuer(personId, ziel);
- const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht });
+ /* DAS HAUS WIRD AUS DEM ECHTEN ZIEL BESTIMMT, nicht aus dem
+ gewuenschten. Wird jemand von `zielFuer` auf die Startseite
+ umgeleitet, weil die Seite fuer ihn nicht existiert, soll auch das
+ Gesicht dazu passen -- sonst steht das Crew-Symbol ueber einer
+ Meldung, die auf der Agenturstartseite landet. */
+ const haus = hausFuerMeldung(personId, zielEcht);
+ const inhalt = JSON.stringify({ titel, text, art, ziel: zielEcht, haus });
let raus = 0;
for (const a of anmeldungen) {
@@ -487,7 +634,12 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
db().prepare("INSERT OR REPLACE INTO push_verschickt (person_id, merkmal, zeit) VALUES (?,?,?)")
.run(personId, merkmal, jetzt());
}
- return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt", ziel: zielEcht };
+ /* `haus` kommt mit zurueck, damit eine Pruefung nachsehen kann,
+ welches Gesicht verschickt wurde, ohne die verschluesselte
+ Nachricht aufmachen zu muessen -- derselbe Gedanke wie beim
+ Ziel. */
+ return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt",
+ ziel: zielEcht, haus };
}
/* ---------------------------------------------------------------------
diff --git a/server/workspace-reaktion.js b/server/workspace-reaktion.js
index d5998515..ec69db4a 100644
--- a/server/workspace-reaktion.js
+++ b/server/workspace-reaktion.js
@@ -66,6 +66,10 @@ import {
Sendung weiterschreibt -- genau die Luecke, die man erst bemerkt,
wenn sie benutzt wird. */
import { treffSperreLesen } from "./treff-tabellen.js";
+/* Wer zum Crew-Haus gehoert, steht in `crew-adresse.js` -- die
+ Einladung liest dieselbe Menge wie die Hausweiche, statt eine
+ zweite danebenzustellen. */
+import { AGENTUR_ROLLEN } from "./crew-adresse.js";
export const reaktionRouter = express.Router();
@@ -677,6 +681,13 @@ reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ lim
});
const l = lage(req.person);
melden("reaktion", { was: "stand", lage: oeffentlich(l) });
+
+ /* ERST JETZT EINLADEN, nach dem Schreiben und nach `melden`.
+ Wer die Meldung antippt, landet auf einer Seite, die den
+ neuen Stand schon kennt -- sonst sieht der Schnellste einen
+ Saal, der noch zu ist. */
+ if (neu === "live" && s.stand !== "live") einladen(sendung(), req.person.id);
+
res.json(l);
} catch (fehler) {
console.error("[reaktion] Stand:", fehler?.message);
@@ -1495,6 +1506,105 @@ reaktionRouter.post("/workspace/api/reaktion/werbung", nurHost,
}
});
+/* =====================================================================
+ DIE EINLADUNG -- wenn die Sendung aufgeht (29.09.2026)
+
+ Filipe: „ich will dass du die benarichtigungen perfektionierst.
+ dogfather und alle anderen sollen die benachrichtigungen perfekt
+ von dieser seite hier bekommen."
+
+ NACHGEMESSEN WAR DIESE DATEI STUMM: kein einziges `benachrichtige`.
+ Geht die Sendung auf Sendung, ruft sie `melden("reaktion", ...)` --
+ und das laeuft ueber den Ereignisstrom, also NUR zu Leuten, die die
+ Seite ohnehin schon offen haben. Wer nicht hinschaut, erfaehrt
+ nichts. Das Kino macht auf, und die Einladung verlaesst das Haus
+ nie.
+
+ Genau dasselbe Muster wie bei den Bewerbungen am 23.09.: beide Wege
+ gebaut, beide stumm.
+ ===================================================================== */
+
+/** Wer wird eingeladen?
+ *
+ * Alle mit einem angemeldeten Geraet, deren Rolle NICHT der Agentur
+ * gehoert -- das ist woertlich die Hausteilung aus `crew-adresse.js`
+ * und keine neue Liste. Ein Scout bekaeme sonst eine Einladung in
+ * ein Kino, dessen Tuer fuer ihn nicht existiert: `zielFuer` schickt
+ * ihn auf die Startseite, und er stuende vor einer Meldung, die
+ * nirgendwohin fuehrt.
+ *
+ * OHNE DEN, DER EINGESCHALTET HAT. Er hat gerade den Knopf
+ * gedrueckt. */
+function eingeladene(ausser) {
+ return db().prepare(`SELECT DISTINCT a.person_id AS id, p.rolle
+ FROM push_anmeldungen a JOIN personen p ON p.id = a.person_id`).all()
+ .filter((z) => z.id !== ausser && !AGENTUR_ROLLEN.has(z.rolle))
+ .map((z) => z.id);
+}
+
+/** Lief eben schon eine Einladung raus?
+ *
+ * DIE BREMSE GEGEN DEN NEUSTART. Das Merkmal allein reicht nicht:
+ * Es haengt an `gestartet_am`, und das wird bei jedem Wechsel nach
+ * „live" neu gesetzt. Wer aus Versehen auf „Vorbereitung" und gleich
+ * wieder auf „Auf Sendung" drueckt -- oder wem der Rechner
+ * abstuerzt --, laedt sonst zweimal ein. Acht Menschen bekommen dann
+ * zwei Meldungen fuer dieselbe Sendung, und das ist der schnellste
+ * Weg, dass jemand die Art abschaltet.
+ *
+ * ZWEI STUNDEN, weil eine Reaction etwa so lange dauert. Wer danach
+ * eine zweite, wirklich neue Sendung aufmacht, laedt zu Recht neu
+ * ein.
+ *
+ * Gefragt wird `push_verschickt` -- also das, was WIRKLICH rausging.
+ * Wurde die erste Einladung verschluckt (Ruhezeit, kein Geraet),
+ * steht dort nichts, und der zweite Versuch darf. */
+function ebenSchonEingeladen() {
+ const z = db().prepare(
+ `SELECT MAX(zeit) AS zuletzt FROM push_verschickt
+ WHERE merkmal LIKE 'reaktion_live:%'`).get();
+ if (!z?.zuletzt) return false;
+ return (Date.now() - Date.parse(z.zuletzt)) < 2 * 60 * 60 * 1000;
+}
+
+/** Die Einladung verschicken -- ohne die Antwort aufzuhalten.
+ *
+ * Der Knopf „Auf Sendung" muss sofort reagieren. Acht
+ * Verschluesselungen und acht Netzwege dauern laenger als die
+ * Antwort dauern darf, und wenn ein Push-Dienst gerade klemmt,
+ * haenge die ganze Sendung daran. Deshalb losgeschickt und nicht
+ * abgewartet -- ein Fehler dabei steht im Protokoll, nicht im Weg. */
+function einladen(sendungJetzt, hostId) {
+ (async () => {
+ try {
+ if (ebenSchonEingeladen()) {
+ console.log("[reaktion] Einladung uebersprungen -- vor weniger als "
+ + "zwei Stunden lief schon eine.");
+ return;
+ }
+ const { benachrichtige } = await import("./workspace-push.js");
+ const wer = eingeladene(hostId);
+ const titel = String(sendungJetzt.video_titel || sendungJetzt.titel || "").trim();
+ let raus = 0;
+ for (const id of wer) {
+ const e = await benachrichtige(id, "reaktion_live", {
+ titel: "Die Reaction läuft",
+ /* WAS GESCHAUT WIRD, STEHT DRIN -- das ist der Unterschied
+ zwischen „komm rein" und „komm rein, wir schauen X". Ist
+ kein Titel da, wird keiner erfunden. */
+ text: titel ? `Wir schauen: ${titel}` : "DogFather hat die Sendung aufgemacht.",
+ ziel: "/workspace/reaktion.html",
+ merkmal: `reaktion_live:${sendungJetzt.gestartet_am || jetzt()}`,
+ });
+ if (e?.verschickt) raus++;
+ }
+ console.log(`[reaktion] Einladung an ${raus} von ${wer.length} verschickt.`);
+ } catch (f) {
+ console.error("[reaktion] Einladung:", f?.message);
+ }
+ })();
+}
+
/* Der Stand der Einblendung -- an EINER Stelle gerechnet. Drei
Aufrufer (setzen, anlegen, aendern) brauchen dasselbe Ergebnis;
dreimal abgeschrieben waere die Gelegenheit, dass einer davon
diff --git a/tools/abzeichen-bauen.mjs b/tools/abzeichen-bauen.mjs
new file mode 100644
index 00000000..1f6a94af
--- /dev/null
+++ b/tools/abzeichen-bauen.mjs
@@ -0,0 +1,172 @@
+/* =====================================================================
+ tools/abzeichen-bauen.mjs — das Abzeichen der Benachrichtigungen.
+
+ WAS EIN ABZEICHEN IST, UND WAS NICHT
+
+ Eine Benachrichtigung hat unter Android ZWEI Bilder:
+
+ `icon` — das bunte Symbol im aufgeklappten Meldungskasten.
+ `badge` — das winzige Zeichen oben in der Statusleiste, 24 dp.
+
+ Vom `badge` benutzt das System AUSSCHLIESSLICH den Alphakanal und
+ malt ihn einfarbig weiß. Alles Bunte daran geht verloren.
+
+ Bis zum 29.09.2026 stand dort `workspace-192.png` — ein
+ vollflächiges, quadratisches Bild OHNE Transparenz. Dessen
+ Alphakanal ist überall voll. In der Statusleiste erschien deshalb
+ ein ausgefüllter Klotz und kein Zeichen. Das fällt nicht als Fehler
+ auf, weil nichts abstürzt; es sieht nur billig aus.
+
+ ---------------------------------------------------------------------
+ WARUM EINE FLÄCHE UND KEIN STRICHBILD
+
+ Drei Fassungen gebaut und bei echter Größe verglichen, statt zu
+ raten:
+
+ A) die Tinte des Logos (Strichbild) — bei 24 px ein Gekrissel
+ B) dieselbe, verdickt — besser, immer noch unruhig
+ C) der geschlossene Umriss, gefüllt — trägt
+
+ Die Ohrkerbe des Huskys ist bei 512 px zwei Prozent der Breite. Bei
+ 24 px ist sie ein halber Pixel und wird zu Grau. Eine Fläche
+ überlebt das Verkleinern, ein Strich nicht.
+
+ ---------------------------------------------------------------------
+ EIN ABZEICHEN FÜR BEIDE HÄUSER — UND ZWAR ABSICHTLICH
+
+ Crew und Agentur sind getrennt, und das gilt auch hier: Das bunte
+ `icon` ist je Haus ein anderes (crew-192 / workspace-192). Das
+ Abzeichen ist es NICHT, und das ist keine vergessene Trennung.
+
+ Es ist 24 Pixel groß und einfarbig. Der Unterschied zwischen den
+ beiden Symbolen ist die Chili der Agentur — bei 24 px ein Fleck von
+ zwei Pixeln, der nicht als Chili lesbar ist, sondern als Schmutz
+ neben dem Kopf. Der Husky ist die Marke beider Häuser; ihn zu
+ teilen verrät nichts über das jeweils andere.
+
+ ---------------------------------------------------------------------
+ WARUM DAS EIN SKRIPT IST UND KEIN EINMALIGER HANDGRIFF
+
+ Ändert sich das Logo, wird das Abzeichen mit einem Befehl neu
+ gebaut. Ein von Hand zugeschnittenes PNG wäre in dem Moment eine
+ Kopie, die niemand mehr pflegt — dieselbe Falle wie eine
+ abgeschriebene Liste.
+
+ node tools/abzeichen-bauen.mjs
+ ===================================================================== */
+
+import { spawnSync } from "node:child_process";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { existsSync, statSync } from "node:fs";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const REPO = join(HIER, "..");
+const QUELLE = join(REPO, "assets", "img", "marke", "logo-kopf-frei.png");
+const ZIEL = join(REPO, "assets", "img", "abzeichen-96.png");
+
+/* Die Rechnung steht in Python, weil dort Pillow liegt — Node hat im
+ Haus keine Bildbibliothek, und eine dafür zu installieren wäre eine
+ Abhängigkeit für 40 Zeilen. */
+const RECHNUNG = String.raw`
+import sys
+from collections import deque
+from PIL import Image
+import numpy as np
+
+quelle, ziel = sys.argv[1], sys.argv[2]
+i = Image.open(quelle).convert("RGBA")
+
+# DIE ZEICHNUNG STECKT IM ALPHAKANAL. Gemessen: RGB ist in dieser
+# Datei ueberall schwarz, das weisse Fell ist DURCHSICHTIG, nicht
+# weiss. Wer hier auf Helligkeit schwellt, bekommt ein leeres Bild.
+tinte = np.array(i.split()[3]) > 90
+h, w = tinte.shape
+if not tinte.any():
+ sys.exit("Die Quelle hat keine Zeichnung im Alphakanal.")
+
+# ---- Von aussen fluten: was der Rand erreicht, ist aussen ----------
+aussen = np.zeros((h, w), bool)
+st = deque()
+for x in range(w):
+ for y in (0, h - 1):
+ if not tinte[y, x] and not aussen[y, x]:
+ aussen[y, x] = True; st.append((y, x))
+for y in range(h):
+ for x in (0, w - 1):
+ if not tinte[y, x] and not aussen[y, x]:
+ aussen[y, x] = True; st.append((y, x))
+while st:
+ y, x = st.pop()
+ for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
+ ny, nx = y + dy, x + dx
+ if 0 <= ny < h and 0 <= nx < w and not tinte[ny, nx] and not aussen[ny, nx]:
+ aussen[ny, nx] = True; st.append((ny, nx))
+voll = ~aussen
+
+# ---- Nur die groesste zusammenhaengende Flaeche -------------------
+# Im Logo liegen unter dem Kinn 18 lose Sprenkel. Bei 512 px sind sie
+# Beiwerk, bei 24 px sind sie Schmutz neben dem Zeichen. Gezaehlt
+# statt weggefiltert: Ein Filter muesste raten, wie dick ein Sprenkel
+# hoechstens ist.
+marke = np.zeros((h, w), np.int32)
+nr, gr = 0, {}
+for sy in range(h):
+ for sx in range(w):
+ if voll[sy, sx] and marke[sy, sx] == 0:
+ nr += 1; n = 0
+ s2 = deque([(sy, sx)]); marke[sy, sx] = nr
+ while s2:
+ y, x = s2.pop(); n += 1
+ for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
+ ny, nx = y + dy, x + dx
+ if 0 <= ny < h and 0 <= nx < w and voll[ny, nx] and marke[ny, nx] == 0:
+ marke[ny, nx] = nr; s2.append((ny, nx))
+ gr[nr] = n
+best = max(gr, key=gr.get)
+kopf = Image.fromarray(((marke == best) * 255).astype(np.uint8), "L")
+
+# ---- Zuschneiden, mittig, etwas Luft ------------------------------
+k = kopf.getbbox()
+aus = kopf.crop(k)
+s = max(aus.size); rand = int(s * 0.06)
+feld = Image.new("L", (s + 2 * rand, s + 2 * rand), 0)
+feld.paste(aus, (rand + (s - aus.width) // 2, rand + (s - aus.height) // 2))
+
+# ---- 96 px, weiss, Form im Alphakanal -----------------------------
+maske = feld.resize((96, 96), Image.LANCZOS)
+bild = Image.new("RGBA", (96, 96), (255, 255, 255, 255))
+bild.putalpha(maske)
+bild.save(ziel, optimize=True)
+
+deckung = (np.array(maske) > 127).sum() / (96 * 96)
+print(" %d Flaechen, %d Sprenkel verworfen, Deckung %.0f %%"
+ % (nr, nr - 1, 100 * deckung))
+# DIE ZAHL GEHOERT IN DIE BEDINGUNG. Ein Abzeichen, das 2 % deckt, ist
+# ein Staubkorn; eines mit 90 % ist der Klotz, den wir gerade
+# abschaffen. Beides faellt hier auf, nicht erst auf dem Telefon.
+if not (0.15 <= deckung <= 0.75):
+ sys.exit("Deckung %.0f %% liegt ausserhalb des Sinnvollen." % (100 * deckung))
+`;
+
+if (!existsSync(QUELLE)) {
+ console.error(`Die Quelle fehlt: ${QUELLE}`);
+ process.exit(2);
+}
+
+const lauf = spawnSync("python", ["-c", RECHNUNG, QUELLE, ZIEL], { encoding: "utf8" });
+if (lauf.error?.code === "ENOENT") {
+ /* DER DRITTE AUSGANG. „Python ist nicht da" ist etwas anderes als
+ „das Abzeichen ist falsch" — und darf nicht wie ein Befund
+ aussehen. */
+ console.error("Python ist auf diesem Rechner nicht im Pfad — das Abzeichen "
+ + "wurde NICHT neu gebaut. Die vorhandene Datei bleibt, wie sie ist.");
+ process.exit(2);
+}
+if (lauf.stdout) process.stdout.write(lauf.stdout);
+if (lauf.status !== 0) {
+ console.error(lauf.stderr?.trim() || "Der Bau ist gescheitert.");
+ process.exit(1);
+}
+const gross = statSync(ZIEL).size;
+console.log(` ${ZIEL.replace(REPO, "").replace(/\\/g, "/")} — ${gross} Bytes`);
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 7eb492d3..4e7fda57 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+