Compare commits

..
35 Commits
Author SHA1 Message Date
DogFatherGitandClaude Opus 5 8a9ab66c44 Angemeldet bleiben: /workspace/ zeigte die Anmeldung trotz Sitzung
Filipe: „ich will dass wenn sich jemand bei der workspace seite
anmeldet, angemeldet bleibt bis er sich selbst abmeldet."

ERST GEMESSEN, DANN GEBAUT -- und das hat die Richtung gedreht.

Die naheliegende Antwort waere gewesen, die Sitzung zu verlaengern.
Sie war aber nie zu kurz:

  * Der Keks gilt 180 Tage und verlaengert sich beim Benutzen.
  * In der echten Datenbank lief KEINE der 28 Sitzungen vor Maerz
    2027 ab.
  * Am echten Browser nachgestellt: Er uebersteht dreimaliges
    Neuladen, den Service Worker UND das Schliessen des Browsers.

Trotzdem meldeten sich dieselben Geraete staendig neu an. DogFathers
Android am 05.10. um 22:54 UND um 22:55 -- gleiche IP, gleiche
Browserkennung, eine Minute auseinander. Kein zweites Geraet.

DER GRUND: `/workspace/` lieferte die Anmeldung aus, OHNE zu fragen,
ob schon jemand angemeldet ist. Das ist die Adresse, die man tippt,
als Lesezeichen hat und weitergibt. Wer sie oeffnete, sah das
Codefeld und tippte den Code noch einmal -- obwohl seine Sitzung die
ganze Zeit gueltig war. Jede Eingabe legt eine neue Sitzung an;
deshalb standen sieben davon bei einer Person.

Haette ich die Sitzungsdauer erhoeht, waere der Fehler geblieben und
die Sicherheit schlechter geworden.

(Die installierte App startet auf `start.html` und war nie betroffen.
Es traf nur den Weg ueber die Adresszeile -- also den haeufigsten.)

WARUM DIE WEITERLEITUNG AUF DEM SERVER STEHT

Mein erster Entwurf stand in gate.js und fragte `/api/ich`. Er
funktionierte und war trotzdem die schlechtere Loesung:

  * Ohne Sitzung antwortet `/api/ich` mit 401 -- danach stand bei
    JEDEM Aufruf der Anmeldung ein Fehler in der Browserkonsole.
    `pruef-neue-seiten` wurde davon zehnmal rot, und eine Meldung,
    die immer kommt, liest nach dem zweiten Mal niemand mehr.
  * Die Seite musste waehrend der Abfrage verdeckt werden, und diese
    Verdeckung brauchte wieder eine Notfrist, damit sie bei
    schlechtem Netz nicht haengen bleibt.
  * Und es brauchte eine Schleifensperre mit Zeitstempel in zwei
    Dateien -- deren erste Fassung prompt jeden ZWEITEN Aufruf auf
    der Anmeldung stehen liess. Die Pruefung hat genau das gefunden.

Drei Hilfskonstruktionen fuer etwas, das der Server in einer Zeile
weiss: Er liest die Sitzung ohnehin bei jeder Anfrage. Jetzt sieht
man die Anmeldung gar nicht erst, es gibt keinen Fehler in der
Konsole, und es geht auch ohne JavaScript. Die drei Hilfskonstruktionen
sind wieder entfernt; gate.css ist unveraendert.

BEIDE HAEUSER, OHNE SIE ZU VERMISCHEN: Auf der Crew-Adresse liefert
`/workspace/` die Datei crew-index.html -- derselbe Weg, dasselbe
Ziel. Der Keks bleibt host-gebunden: Wer nur im anderen Haus
angemeldet ist, bekommt weiterhin die Anmeldung, genau wie es die
Haustrennung vom 24.09.2026 verlangt. pruef-crew-adresse bleibt gruen.

NEU: pruef-angemeldet-bleiben.mjs (22 Pruefungen, 0 Fehler)

Mit einem echten Browserprofil auf der Platte -- nur so ueberlebt ein
Keks das Schliessen des Browsers; ein gewoehnlicher Kontext vergisst
alles, und dann maesse die Pruefung ihre eigene Einrichtung.

DIE WICHTIGSTE ZEILE IST DIE ZAHL DER SITZUNGEN. „Man bleibt
angemeldet" laesst sich leicht vortaeuschen: Wer bei jedem Besuch
stillschweigend neu anmeldet, sieht auch nie ein Codefeld. Deshalb
wird durchgehend gezaehlt -- es bleibt bei EINER, ueber drei Aufrufe
und einen Browserneustart hinweg.

Dazu die Gegenprobe in beide Richtungen: Abmelden beendet es
wirklich (die Sitzung ist weg, die Anmeldung bleibt stehen, kein Hin
und Her), und ohne Keks fuehrt `/workspace/` nicht zur Startseite --
die Messung kann also auch Nein sagen.

DREI MEINER EIGENEN MESSUNGEN WAREN ZUERST FALSCH, nicht der Code:
Die Pruefung klickte „Abmelden" ohne die Rueckfrage zu bestaetigen
und meldete dann „die Sitzung ist nicht weg"; ihre Knopfauswahl traf
einen unsichtbaren Knopf und lief 30 Sekunden in eine
Zeitueberschreitung; und die erste Schleifensperre war zu grob.
Alle drei berichtigt, bevor sie als Befund durchgingen.

NICHT VON MIR: pruef-css-klassen meldet weiterhin 44 statt 42
Schriftgroessen unter 11,5 px (uebersicht.css, wissen.css) -- derselbe
aeltere Befund wie gestern.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-06 01:14:27 +02:00
DogFatherGitandClaude Opus 5 c5e3ab1c44 Zwei Meldungen aus dem Support: Auswahlmenues und die Stelle im Chat
DIENE: "Beim Kalender laesst sich die Art noch nicht einstellen, das
Menue ploppt nur ganz kurz auf und verschwindet direkt wieder. Das
selbe bei den Wiederholungen."

In wahl.js stand `addEventListener('resize', schliessen)`. Auf dem
Rechner ist das harmlos -- dort aendert sich die Fenstergroesse nur,
wenn jemand sie aendert. Auf einem Handy aendert sie sich BEIM
BEDIENEN: Die Adressleiste faehrt beim kleinsten Scrollen ein und
aus, die Tastatur kommt und geht, und jedes Mal feuert `resize`. Die
Liste ging auf, der Browser meldete eine neue Hoehe, und sie war
wieder zu.

Es ist derselbe Fehler wie am 04.09. beim Scrollen ("wenn ich da
scollen will geht das immer zu"), nur eine Zeile tiefer: Ein
Ereignis, das beim BEDIENEN entsteht, wird als Grund zum Abbrechen
genommen. Jetzt wird die Liste neu ausgerichtet statt geschlossen --
`stelle()` kann das ohnehin, und nach einer Groessenaenderung muss
sie es sowieso.

pruef-suchfeld 8 -> 15, an Dienes genauem Fall: Kalenderformular,
beide Felder, echte Groessenaenderung dazwischen. Gemessen wird nicht
nur "offen", sondern auch "sitzt noch am Knopf" (6 px) -- offen, aber
verrutscht waere nur die halbe Antwort. Gegenprobe: mit der alten
Zeile 4 Fehler.

----------------------------------------------------------------------

MISS: "Wenn ich auf den Chat gehen, komme ich zuerst auf die erste
neue Nachricht, aber kurze Zeit spaeter springt er auf die zuletzt
geschrieben Nachricht."

In chat.js stand `unten = true; neuUnten = 0;` AUSSERHALB von
`if (!sanft)` -- es lief also bei jedem Nachladen, auch beim sanften,
das staendig passiert (Strom verbindet, jemand heftet etwas an, eine
Nachricht kommt).

Solange die Neu-Linie steht, faellt das nicht auf: Der Zweig darueber
springt dorthin und kehrt zurueck, bevor `unten` gelesen wird. Die
zweite Haelfte ist `wache` -- sie loescht den Sprung-Merker beim
ersten FINGERTIPP auf den Verlauf, und das ist richtig so. Ab da ist
der Weg frei, und das naechste sanfte Nachladen findet `unten ===
true` vor und reisst die Ansicht ans Ende. Genau ihre "kurze Zeit
spaeter".

Ein sanftes Nachladen ist kein Oeffnen. Wo jemand steht, weiss ab
jetzt allein der Scroll-Horcher -- und der misst es, statt es
anzunehmen.

DREI FEHLVERSUCHE BIS ZUR MESSUNG, und sie gehoeren ins Protokoll:
Erst sechs Sekunden Nichtstun, dann ein Fingertipp auf Koordinaten,
dann ein Verbindungsabriss -- alle drei waren mit dem ALTEN Code
gruen. Eine Pruefung, die den Fehler nicht herstellt, misst nichts,
und ich haette sie beinahe als Beweis genommen. Was fehlte: Der Griff
muss den Verlauf WIRKLICH treffen (Koordinaten gehen daneben), und es
braucht einen echten sanften Nachlauf -- hier das Anheften, das ein
`pin`-Ereignis an alle im Raum schickt.

Erst damit flippt die Gegenprobe: ohne Korrektur 500 -> 7054 px und
`amEnde: true`, mit Korrektur bleibt es bei 500.

pruef-chat-neu-stelle 62 -> 69.

Unterwegs wieder entfernt: ein Merker `schonGesprungen`, den ich
zuerst eingebaut hatte. Nach der echten Korrektur ist er
unerreichbar, und ich konnte ihn mit keiner Messung zum Greifen
bringen. Ein Zweig, den nichts erreicht, sieht beim Lesen aus wie ein
Fall, den es gibt.

Gegengemessen: pruef-chat 80, pruef-chat-optik 84, pruef-freie-namen
32 -- 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 22:41:18 +02:00
DogFatherGitandClaude Opus 5 4e67f8a6c2 Der schwarze Bildschirm: der Dialog stand ausserhalb des Fensters
Patrick ueber den Support: „Sophie kann Abschnitt 3 und 4 nicht
bestaetigen, beim druecken kommt ein schwarzer Bildschirm."

DER DIALOG WAR DIE GANZE ZEIT DA -- nur nicht im Bild. Nachgebaut auf
einer KOPIE der echten Datenbank, mit einem echten Browser, und dann
gemessen statt vermutet:

    position         absolute    statt fixed
    oben im Fenster   -143 px    also oberhalb des Sichtbaren

`module.css` setzte `.dialog { position: relative }`. Damit war die
Zentrierung des Browsers ueberschrieben: Ein Dialog aus `showModal()`
gehoert mit `position: fixed` in die Mitte des SICHTBAREN FENSTERS,
mit `relative` landet er im Dokumentfluss nahe dem SEITENANFANG. Wer
nach unten gescrollt hatte, sah nur noch den abdunkelnden Schleier --
einen schwarzen Bildschirm.

WARUM AUSGERECHNET „ABSCHNITT 3 UND 4"

Gar nicht wegen dieser beiden. Sophie hatte 1 und 2 schon bestaetigt,
dort gibt es keinen Knopf mehr -- 3 und 4 waren die einzigen, die sie
ueberhaupt noch druecken konnte, und sie stehen am weitesten unten.
Der Fehler hing nie an den Abschnitten, sondern an der Scrollhoehe.
Haette ich die Meldung woertlich genommen und in den Unterweisungen
gesucht, haette ich an der falschen Stelle gegraben.

ES BETRAF JEDEN DIALOG IM HAUS

22 Aufrufe von `showModal()` in 10 Dateien -- vom Loeschen einer Datei
ueber das Bearbeiten einer Aufgabe bis zum Eintragen eines
Monatsziels. Auf kurzen Seiten fiel es nie auf, weil dort niemand
scrollt. Keine einzige Pruefung im Haus hatte je einen Dialog
geoeffnet, NACHDEM sie gescrollt hat.

`relative` stand dort fuer die beiden Pseudo-Elemente (Leuchtschiene
und Lichtsaum) -- die brauchen einen positionierten Vorfahren, und
`fixed` ist ebenfalls einer. `inset: 0` und `margin: auto` schreiben
die Zentrierung jetzt ausdruecklich hin, statt sich auf eine Vorgabe
zu verlassen, die diese Datei selbst ueberschreibt.

WAS ICH FAST KAPUTT GEMACHT HAETTE

Mein erster Entwurf setzte zusaetzlich `max-height` und
`overflow: auto` auf den Dialog. Das waere ein Rueckschritt gewesen:
Das Rollen ist eine Ebene tiefer laengst geloest (`.dialog > form`),
gemessen am 25.09.2026 ueber vier Bildschirmgroessen, nachdem Miss
gemeldet hatte, dass sie aus einem Fenster nicht mehr herauskam. Dort
haengen auch die festen Kopf- und Fusszeilen -- und `position: sticky`
gilt immer zum naechsten rollenden Vorfahren. Ein zweiter Rollbereich
darueber haette genau die wieder geloest. Wieder entfernt;
`mess-dialog-ausgang` meldet unveraendert auf allen vier Groessen
„Kommt man heraus? JA".

NEU: pruef-dialog-sichtbar.mjs (18 Pruefungen, 0 Fehler)

Drei Ebenen, und die unterste allein waere zu wenig:

  * Im Quelltext: keine Regel darf `.dialog` wieder aus dem Fenster
    nehmen -- in ALLEN Stilvorlagen, nicht nur in module.css.
  * Am echten Bildschirm: zwei verschiedene Dialoge auf zwei
    verschiedenen Seiten, je auf Computer und Handy, jedes Mal ganz
    nach unten gescrollt (663 bis 1216 px).
  * Die Gegenprobe: Ein aus dem Fenster geschobener Dialog MUSS
    auffallen. Ohne sie waere „er steht im Bild" womoeglich eine
    Zeile, die immer wahr ist.

DREI MEINER EIGENEN MESSUNGEN WAREN ZUERST FALSCH, nicht der Code:
Die Textsuche fand Kommentare und `.dialog > *` (die Kinder DUERFEN
relativ sein); die Schulungstabellen entstehen erst beim ersten
Zugriff; und die Auswahl fuer den zweiten Dialog war seit dem
Schnell-Eintrag veraltet -- der erste Knopf oeffnet dort gar keinen
Dialog mehr. Alle drei berichtigt, bevor sie als Befund durchgingen.

NICHT VON MIR, aber beim Nachmessen aufgefallen: pruef-css-klassen
meldet 44 statt 42 Schriftgroessen unter 11,5 px (uebersicht.css,
wissen.css). Meine Aenderung fasst keine einzige Schriftgroesse an --
module.css hat vorher wie nachher denselben Wert. Der Befund ist
aelter und gehoert nicht hierher.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 19:32:53 +02:00
DogFatherGitandClaude Opus 5 25cf6a0163 Fotoposts von TikTok kommen jetzt an -- das war die Ursache
GEFUNDEN HAT ES DAS PROTOKOLL, das eine halbe Stunde vorher dazukam.
Filipes Versuche standen Minuten spaeter als `video_gescheitert`
da, mit Grund:

  highlight .../@dogfather0804/photo/7693113561568136470 -- 400
  highlight https://vm.tiktok.com/ZN8kdxP9N/             -- 400

Am echten Konto nachgemessen:

  .../photo/7693113561568136470   -> 400 "Something went wrong"
  .../video/7693113561568136470   -> 200, mit Titel
  vm.tiktok.com/ZN8kdxP9N/        -> leitet auf die /photo/-Adresse

TikToks Auskunft kennt FOTOPOSTS (Slideshows) nicht -- dieselbe
Nummer unter /video/ aber schon. Filipe postet seit kurzem solche
Beitraege. Deshalb "es ging doch immer bis jetzt": Es lag weder an
einer Auslieferung noch am Haus, sondern an der Art des Posts. Der
Server war die ganze Zeit unveraendert (ein Neustart seit 04.10.),
und genau das hat die Suche in die falsche Richtung geschickt.

Jetzt wird bei einem Fehlschlag ein zweites Mal gefragt: erst den
Kurzlink aufloesen -- er verraet von aussen nicht, was dahinter
steckt --, dann /photo/ auf /video/ drehen. Aufgeloest wird nur ueber
den Ort-Kopf (`redirect: "manual"`), die Seite selbst wird nie
geholt; sie ist ein Megabyte gross und enthaelt nichts, was wir
brauchen.

NUR BEIM FEHLSCHLAG: Ein gewoehnliches Video kostet keine einzige
zusaetzliche Anfrage. Gemessen: Video 204 ms wie bisher, Fotopost
676 ms, Kurzlink auf Fotopost 827 ms -- alle drei mit Titel und
Vorschaubild.

Gegengemessen an genau den zwei Adressen, die bei ihm gescheitert
sind: beide liefern jetzt Titel und Bild. pruef-video 74,
pruef-teilen 27 -- 0 Fehler.

WAS ICH DARAUS MITNEHME: Ich habe heute stundenlang von aussen
ausgeschlossen, was es NICHT ist -- Adresse, Haus, Sitzung,
Manifest, Service Worker, Zwischenspeicher. Beantwortet hat die
Frage am Ende eine einzige Protokollzeile. Die haette am Anfang
stehen muessen, nicht nach der halben Suche. Dieselbe Lehre wie am
06.09. im Shop, und ich habe sie heute zweimal gebraucht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 12:47:42 +02:00
DogFatherGitandClaude Opus 5 b42bbb1d88 Video einlesen: die Frist galt nur fuer den Anfang -- und kein Fehlschlag stand im Protokoll
Filipe: "ES GING DOCH IMMER BIS JETZT." Das war der nuetzlichste Satz
des Tages, denn er stimmt: Zwischen seinem letzten Erfolg (04.10.
13:44) und der Meldung wurde NICHTS ausgeliefert -- gemessen am
Server: ein einziger Neustart seit dem 04.10. 13:00, meiner von
heute 12:19. Am Haus hat sich nichts geaendert.

Ein Fehler, der ohne Aenderung anfaengt, haengt an etwas von
draussen. Und genau dafuer war die Frist zu kurz gebaut:

  holeMitFrist() gab die Antwort zurueck, sobald die KOPFZEILEN da
  waren, und raeumte im finally die Uhr weg. Was danach kam --
  a.json() fuer die Videodaten, a.arrayBuffer() fuer das
  Vorschaubild -- lief OHNE jede Frist.

Bleibt TikToks Bildserver mitten im Koerper stehen, wartet unsere
Anfrage fuer immer. Von aussen sieht das genau so aus, wie Filipe es
beschrieben hat: "der Knopf bleibt auf `wird geholt ...` stehen" --
keine Meldung, kein Eintrag, kein Protokolleintrag, weil der Vorgang
nie endet. Das Vorschaubild ist dabei kein Nebending: das letzte war
1,29 MB.

Der Koerper wird jetzt gelesen, SOLANGE die Uhr laeuft. Acht
Sekunden gelten fuer alles zusammen.

NACHGEMESSEN, nicht vermutet: oembed antwortet dem Server in 0,2 s,
das Bild laedt in 0,1 s -- heute haengt es also nicht. Der Fehler
ist trotzdem echt und erklaert genau dieses Bild; er kommt und geht
mit dem fremden Netz.

UND DER ZWEITE TEIL, der die Suche so teuer gemacht hat: Im
Protokoll stand nur der ERFOLG (video_eingelesen). Blieb TikTok eine
Antwort schuldig, gab es hinterher nichts zum Nachsehen -- auf die
Frage "ist der Versuch ueberhaupt angekommen?" liess sich nichts
sagen. Dieselbe Reihenfolge wie am 06.09. im Shop ("erst fragen, OB
gesendet wurde") und wie heute frueh beim Push.

Jetzt steht jeder Fehlschlag als `video_gescheitert` im Protokoll,
mit Grund: keine TikTok-Adresse, TikTok antwortet nicht, oder
Abbruch mit Meldung.

Gegengemessen: mess-teilen weiterhin 201 mit Eintrag und
Vorschaubild, pruef-video 74, pruef-teilen 27, pruef-kanalzeile 14 --
alle 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 12:39:49 +02:00
DogFatherGitandClaude Opus 5 48689e28ac Teilen-Seite kann nicht mehr stumm haengen bleiben
Filipe: "ich lade video hoch und es erscheint immer noch nicht" --
auf Nachfrage: die Seite bleibt haengen, es kommt KEINE Meldung.

WAS GEMESSEN WURDE, bevor etwas angefasst wurde:

  Server zwischen 08:41 und der Meldung   unveraendert, ein Neustart
  Seine Sitzungen                          gueltig, Android-App
  TikTok-Abruf vom Server                  200, auch fuer Kurzlinks
  Seite, Skript, alle 14 Elemente          live auf beiden Adressen
  Service Worker                           speichert nichts zwischen
  Manifeste (beide)                        gueltig, share_target da
  Protokoll heute                          kein video_eingelesen
  Teilen-Weg nachgespielt (mess-teilen)    201, Eintrag, Erfolgssatz

Aus den Caddy-Protokollen ausserdem: Die Handy-Apps laufen auf crew.
(85 Android, 18 iPhone), auf der Agenturadresse fast nur Windows.
Der Riegel von heute Mittag trifft ihn also nicht.

DIE URSACHE IST DAMIT NICHT GEFUNDEN -- aber ein eigener Mangel
schon, und der ist der Grund, warum die Suche so lange gedauert hat:
`los()` baut die Seite auf und macht dabei keine einzige Netzanfrage.
Es kann also nur stolpern, nicht warten. Und wenn es stolpert, wird
`ladezeile.hidden = true` nie erreicht -- die Seite steht fuer immer
auf "wird geladen". Kein Fehler, kein Hinweis, nichts zum
Weitermelden, und von aussen nicht zu unterscheiden von "der Server
antwortet nicht".

Eine Seite, die nicht sagen kann, dass sie kaputt ist, kostet jeden
Fehler doppelt: einmal den Fehler und einmal die Suche danach.

Jetzt nennt sie den Grund im Klartext, oeffnet das Feld zum
Einfuegen von Hand und haengt den Knopf daran -- eine Meldung ohne
Ausweg waere nur ein Trost. Beim naechsten Versuch steht also da,
woran es liegt.

GEGENPROBE GEFAHREN, und sie hat zuerst mich korrigiert: Der erste
Versuch tauschte die Kennung im HTML unterwegs aus und kam nie an --
die Seite lud normal, der Browser meldete nichts, und die Messung
schloss daraus "die Sicherung greift nicht". Eine Gegenprobe, die
ihren eigenen Schaden nicht anrichtet, misst gar nichts. Jetzt wird
`document.getElementById` vor jedem Skript ersetzt, genau das, was
`$` benutzt. Ergebnis: Ladezeile weg, Meldung "Probe: Element fehlt",
Einfuegefeld offen.

mess-teilen.mjs neu: spielt den Teilen-Weg einmal heil und einmal
zerstoert durch.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 12:34:51 +02:00
DogFatherGitandClaude Opus 5 adb265e603 Highlights: auf der Agenturadresse gibt es die Rudel-Bretter nicht mehr
Filipe, dringend: "ich kann gerade bei highlights keine videos
hochladen."

DAS HOCHLADEN WAR NICHT KAPUTT. Gemessen an der laufenden Datenbank:
49 Highlights, ALLE mit haus = crew -- so legt hausFuerNeuenEintrag
sie an, ausdruecklich "egal, von welcher Adresse aus jemand
hineinschreibt". Der Adressriegel vom 03.10. zeigt auf der
Agenturadresse aber nur haus = agentur:

    crew.     -> 49 Highlights
    workspace -> 0

Das Brett stand dort leer, und ein eingefuegtes Video verschwand in
derselben Sekunde: angelegt und sofort ausgeblendet. Von aussen sieht
das aus wie "geht nicht hoch". Beleg: Eintrag #77 wurde heute um
08:41 erfolgreich angelegt, #76 gestern von Filipe selbst.

Im Serverlog stand seit Stunden keine Fehlerzeile, und TikTok
antwortet dem Server normal (HTTP 200 in 0,2 s) -- beides geprueft,
bevor im Code gesucht wurde.

ZWEI REGELN WIDERSPRACHEN SICH, und jede fuer sich war richtig:
"diese Bretter sind immer crew" gegen "diese Adresse zeigt nur
agentur". Keine Pruefung konnte das merken, weil keine den
Zwischenraum ansah.

Aufgeloest in Filipes Richtung (Rueckfrage 05.10.): Auf der
Agenturadresse gibt es diese Bretter gar nicht -- 404 statt leerem
Brett. Das ist die Regel vom 24.09. beim Wort genommen: "auf jeder
Adresse nur deren Bestand."

EINE Regel (brettAufDieserAdresse), zwei Tore: die Middleware in
workspace-bereiche.js und die Video-Route, die NICHT an ihr haengt.
Ohne das zweite liesse sich weiterhin etwas anlegen, das danach
niemand sieht -- derselbe Zustand, nur still.

Ohne Adresse (Pruefungen, 127.0.0.1) bleibt alles offen, dieselbe
vorsichtige Richtung wie in nurDiesesHaus.

ZWEIMAL VON DER MESSUNG KORRIGIERT WORDEN:

1. Ich hatte zusaetzlich einen Kachelfilter gebaut. Er war toter
   Code: Auf der Agenturadresse liefert bereicheRoh `null` ("der
   Browser nimmt seine eigene Liste"), und in dieser stehen die
   Rudel-Bretter gar nicht -- weder in start.html noch in
   bereiche.js. Es gab nichts zu filtern. Wieder entfernt: Ein
   Filter, den nichts erreicht, sieht beim Lesen aus wie der Schutz,
   und man sucht den echten nicht mehr.

2. Meine erste Pruefzeile erwartete auf der Agenturadresse
   Serverkacheln und wurde rot -- zu Recht, ich hatte eine Liste
   vermutet, wo keine ist.

pruef-haus-trennung 100 -> 107, beide Richtungen und alle drei Wege
(Kachel, Brett, Video). Die Gegenprobe auf crew. schickt bewusst
KEINE echte TikTok-Adresse: Der Riegel sitzt vor der Adresspruefung,
also beweist 400 ebenso gut, dass er nicht gesprungen ist -- und der
Lauf geht dafuer nicht ins Netz. Mit einer echten Adresse riss der
Server beim Beenden in einen libuv-Absturz (107 Pruefungen, 1 Fehler,
Rueckgabewert 127).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 12:19:10 +02:00
DogFatherGitandClaude Opus 5 b625d901a5 Jeder darf seine eigene Chat-Nachricht bearbeiten
Filipe: "die nachrichten die man in den chat reinschreibt. jeder soll
seine eigene nachricht bearbeiten koennen. diese option soll jeder
fuer seine eigene nachrichten haben die er selber verfasst hat."

NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme seit dem 22.09.
(fuer den Notfall), und es waere naheliegend gewesen, sie
mitzunehmen. Das waere falsch: Eine fremde Nachricht zu entfernen
heisst "das soll hier nicht stehen". Eine fremde zu AENDERN heisst,
jemandem Worte in den Mund zu legen, die unter seinem Namen und
seinem Bild stehen bleiben. Nicht dieselbe Befugnis in groesser,
sondern eine andere. Genau das ist die wichtigste Pruefzeile:
DogFather darf loeschen und bekommt beim Bearbeiten 403/404.

AN DER NACHRICHT STEHT "BEARBEITET". Ein Text, der sich still
aendert, nachdem andere darauf geantwortet haben, ist ein
Vertrauensproblem und kein Komfort. Der Vermerk traegt die Zeit im
Titel. Derselbe Text setzt ihn NICHT -- sonst stuende er irgendwann
ueberall und waere nichts mehr wert.

ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim Bearbeiten
"@Anna" ergaenzt, spricht Anna damit nicht an. Sonst gaebe es nur
schlechte Wege: nachtraeglich benachrichtigen laesst sich beliebig
oft wiederholen, und still eintragen setzt jemanden auf eine Liste,
von der er nie erfaehrt. Ansprechen tut man mit einer neuen
Nachricht. (Falls das anders gewuenscht ist, ist es eine eigene
Entscheidung -- nicht etwas, das hier nebenbei mitpassiert.)

DER RAUM RUECKT NICHT NACH OBEN und niemand bekommt die Nachricht
als ungelesen: Eine Tippfehlerkorrektur ist keine Wortmeldung.
`letzte_am` wird deshalb nicht angefasst.

Leer geht nicht -- dafuer steht "loeschen" daneben, mit Rueckfrage.
Grenzen (4000 Zeichen) sind dieselben wie beim Senden; eine zweite
Rechnung waere die, die auseinanderlaeuft.

Das Feld sitzt AN der Nachricht, nicht im Schreibfeld unten: Wer
seinen Text zum Bearbeiten unten wiederfindet, schickt ihn beim
naechsten Enter als NEUE Nachricht ab und hat ihn zweimal im Raum.
Enter speichert, Shift+Enter macht eine Zeile, Escape bricht ab --
dieselben Tasten wie beim Schreiben. 16 px Schrift, sonst zoomt iOS
beim Hineintippen die ganze Seite heran.

Der Stift traegt sich in die Familie der Handgriffe ein, wie es der
Hinweis in chat.css ausdruecklich verlangt ("wer einen sechsten
Handgriff baut, traegt ihn hier ein und bekommt sein Zeichen").

EIN FEHLER, DEN NUR DAS BILD GEZEIGT HAT. Ich hatte im Code
behauptet, die Gespraechsliste aendere sich beim Bearbeiten nicht,
und darum auf das Nachladen verzichtet. Auf dem Bildschirmfoto stand
rechts "Treffen um 15 Uhr" und links in der Liste weiter "Du:
Treffen um 15 Urh" -- derselbe Satz, zweimal verschieden, auf einem
Schirm. Richtig ist: Die REIHENFOLGE aendert sich nicht, die
VORSCHAU sehr wohl. Beide Haelften waren fuer sich gemessen und
gruen; keine Zahl hat es gemerkt.

pruef-chat 17 neue Pruefungen: eigene geht, fremde nicht, DogFather
nicht, Vermerk kommt mit nach draussen (auch im SELECT -- genau das
hat am 03.10. bei den Anhaengen einen halben Tag gekostet), Raum
rueckt nicht, Vorschau zieht nach, leer/zu lang abgelehnt,
unveraendert ohne Vermerk, geloeschte nicht bearbeitbar, wer nicht
im Raum ist bekommt 404 statt 403.

pruef-chat-optik 71 -> 84: im echten Browser, mit zwei Sitzungen.
Darunter die Zeile, auf die es ankommt -- der neue Text steht bei
Patrick, OHNE Neuladen. Ein Bearbeiten, das nur der Schreibende
sieht, waere schlimmer als keins.

Dabei zwei eigene Messfehler behoben: Die Sitzungen von oben waren
laengst geschlossen (Playwright meldet nur "Target page has been
closed"), und beide klickten "das oberste Gespraech" statt
denselben Raum -- wodurch die Pruefung "an ihr steht KEIN
bearbeiten" gruen war, weil die Nachricht gar nicht da war. Sie
haengt jetzt daran, dass er sie wirklich sieht.

Datenbank vorher gesichert und zurueckgelesen (integrity_check,
324 Nachrichten, 20 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-04 00:06:29 +02:00
DogFatherGitandClaude Opus 5 5d9c8cd377 Haustrennung: kein Eintrag mehr aus dem anderen Haus
Filipe, 24.09.2026: "wenn ich bei der einen was mache soll nichts bei
der anderen passieren." -- und heute: "ja los".

GEMESSEN, BEVOR ETWAS ANGEFASST WURDE (jedes Brett x beide Adressen x
vier Rollen):

  AGENTUR-Adresse   DogFather  18 Bretter
                    Manager     6 Bretter
                    Creator     6 Bretter
                    Modi        kommt nicht rein (401)
  CREW-Adresse      DogFather  18 Bretter
                    Modi       13 Bretter
                    Manager     kommt nicht rein (401)
                    Creator     kommt nicht rein (401)

Die Anmeldung war also dicht. Durch griff genau EINE Rolle: DogFather.
Er wohnt in beiden Haeusern, und die Riegel in sichtbarEintrag()
fragten nach der ROLLE, nicht nach der Adresse. Auf der Crew-Adresse
stand damit das Brett der Agentur samt Inhalt.

Nachgemessen ist der Durchgriff AELTER als der gestrige Eventkarten-
Umbau -- zweimal gemessen, mit und ohne ihn, gleiches Ergebnis.

RIEGEL 0 in sichtbarEintrag(): Wer auf einer Adresse angemeldet ist,
sieht nur Eintraege dieses Hauses. Er haengt den uebrigen Riegeln
UM, statt in jeden Ausgang geschrieben zu werden -- die Funktion hat
drei Rueckgabepunkte, und der naechste waere sonst wieder offen.

Nachher, dieselbe Messung: DogFather sieht auf der Agenturadresse nur
Agentur-Eintraege, auf der Crew-Adresse nur die des Rudels. Manager,
Creator und Modi unveraendert.

WAS DABEI SCHIEFGING UND WIE ES AUFFIEL

1. Die erste Fassung liess bei `haus IS NULL` den BEREICH entscheiden.
   pruef-haus-trennung.mjs wurde sofort rot: "Lunas Live vom Montag"
   verschwand von der Agenturadresse. `live`, `technik` und
   `community` tragen BEIDES -- die Kacheln von Team Dogi und die
   Creator-Akten. Eine Regel, die jedem Brett genau ein Haus zuweist,
   kann das nicht. Jetzt bleibt ein Eintrag ohne Haus sichtbar: ein
   Eintrag zu viel faellt auf, ein fehlender nicht.

2. Damit NULL kein Dauerloch ist: FUENF von ACHT Stellen, die
   Eintraege anlegen, setzten `haus` gar nicht (workspace-video.js,
   -content.js, -bewerbung.js, -treff.js, -vorlagen.js). Nachgetragen.

3. Und `person.haus` war dafuer der falsche Massstab: Legt DogFather
   ueber die Agenturadresse ein Highlight an, gehoert es trotzdem dem
   Rudel -- sonst sieht die Community es nie. pruef-treff.mjs hat das
   gefunden (2 Fehler). Neu: hausFuerNeuenEintrag() -- bei den sieben
   Brettern des Rudels entscheidet das BRETT, sonst die Adresse.

NEU: pruef-haus-luecke.mjs (12 Pruefungen). Sie sucht die
Einfuege-Stellen im Quelltext und wird rot, sobald eine neunte
dazukommt, die `haus` vergisst -- mit Gegenprobe, dass das Suchmuster
eine solche Stelle auch wirklich erkennt. Ein Kommentar daneben haette
es nicht verhindert; das steht so schon im Projektgedaechtnis.

NEBENBEI: In bereich.js stand seit gestern `|| "Agentur-Events"` als
Rueckfall fuer das Etikett der Vorschau. pruef-treff.mjs verbietet
das zu Recht -- wie ein Brett heisst, haengt am Haus, und der Server
sagt es. Der Rueckfall ist weg; fehlt die Angabe, steht lieber kein
Etikett da als ein falsches.

pruef-treff.mjs nachgezogen (85 -> 86 Pruefungen, nicht weniger): Die
Zusage "DogFather sieht den Beitrag" wird jetzt auf der Crew-Adresse
geprueft, mit Gegenprobe fuer die Agenturadresse.

GEPRUEFT, alle gruen:
  pruef-haus-luecke     12    pruef-haus-trennung  100
  pruef-crew-adresse   169    pruef-treff           86
  pruef-eventkarte      77    pruef-agentur         62
  pruef-haus-seiten     38    pruef-eintrag-bild    24
  pruef-vorlagen        24    pruef-video, -content, -bewerbung,
                              -bereiche-lesend: in Ordnung

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 23:33:03 +02:00
DogFatherGitandClaude Opus 5 3e1e0810ba Agentur-Events: die Karte als Aushang, Aufgaben zum Abhaken
Filipe, mit einem Bildschirmfoto des Oktober-Events: "ich will das viel
geiler viel perfekter. und auch wenn man die eintraegt und so soll viel
mehr perfektioniert und uebersichtlicher sein."

VORHER GEMESSEN (1280 px, eine einzige Eventkarte):

  Kartenhoehe            1196 px   -- das Fenster ist 900 px
  davon Banner            638 px   -- 53 %, ohne einen Buchstaben
  Titel steht bei        1482 px   -- zweimal scrollen bis zum Namen
  Titelgroesse          15,36 px   -- 1,4 px mehr als der Fliesstext
  Etikett               10,88 px   -- unter der eigenen Grenze (11,5)
  Datum                     3 x    -- in drei Zeilen untereinander
  Knoepfe              4 x gleich  -- "loeschen" wie "zur Aufgabe"

NACHHER, dieselbe Messung: Karte 618 px, Buehne 203 px, Titel 75 px
unter der Kartenkante und 24,8 px gross, Etikett 12 px, das Datum
einmal, "loeschen" abgesetzt.

DIE KARTE
Das Banner ist nicht mehr ein Anhang ueber dem Titel, sondern die
Buehne dahinter -- Hoehe gedeckelt, Titel darauf. Dazu die Frage, die
bei einem Wettbewerb wirklich zaehlt und bisher nirgends beantwortet
wurde: "noch 12 Tage", mit Zeitbalken. Ein Knopf "Banner ganz" holt das
vollstaendige Bild zurueck, das beim Umbau sonst verloren gegangen
waere -- bei Filipes Banner steht die Ansage IM Bild.

DIE AUFGABEN
Aus Fliesstext wird eine Liste, und jeder hakt fuer sich ab (neue
Tabelle event_punkte, Schluessel aus dem Zeilentext). Umsortieren
laesst den Haken, wo er ist; wird die Bedingung selbst umgeschrieben,
faellt er -- beides nachgemessen. Die Leitung sieht, wer wie weit ist,
eine Creatorin sieht diese Liste gar nicht erst.

DAS FORMULAR
Ein Feld je Zeile statt eines leeren Textfeldes. Im echten Event stand
".Jeden Tag Live gehen" neben ". 33k Diamanten erreichen" -- einmal mit
Leerzeichen, einmal ohne. Das ist die zwangslaeufige Folge davon, dass
die Aufzaehlungszeichen von Hand getippt werden; jetzt setzt sie das
Formular. Dazu "Laeuft 14 Tage.", eine Warnung bei verdrehtem Zeitraum
und eine Vorschau, die dieselbe Funktion benutzt wie die echte Karte.

NEBENBEI GEFUNDEN UND BEHOBEN
* Das Banner kam bei Creatorn mit 404 zurueck -- also an genau der
  Karte, die fuer sie gemacht ist. Die Regel "wer das Brett sieht,
  sieht das Bild" galt nur fuer die sieben Community-Bretter. Jetzt
  fragt der Bildweg dieselbe Regel wie das Brett (darfEintragSehen);
  die Gegenprobe zeigt, dass ein fremder Eintrag weiterhin 404 gibt.
* Zwei Stellen setzten das Formular zurueck, die kuerzere liess
  Vorschau und Zeilen-Editor stehen -- beim naechsten "Neuer Eintrag"
  standen die Aufgaben des vorigen Events noch da.
* Der Schriftgrund ragte auf dem Handy 6 px ueber die Karte (feste
  -20 px gegen 14 px Polsterung); jetzt an die Polsterung gekoppelt.

GEPRUEFT
pruef-eventkarte.mjs (neu): 77 Pruefungen, 0 Fehler -- mit Gegenproben
zu jeder Zusage und einer Kontrastmessung am Bildpunkt auf einem
weissen Banner (14,7:1; ohne den Schriftgrund 1,05:1).
pruef-agentur.mjs auf die neuen Bausteine nachgezogen, Zahl unveraendert
bei 62. pruef-eintrag-bild.mjs 24/0.

OFFEN, NICHT AUS DIESEM UMBAU: Das Brett `agentur` ist auf der
Crew-Adresse erreichbar. Zweimal gemessen, mit und ohne diese
Aenderung -- gleiches Ergebnis. Gehoert zur Trennungsregel vom
24.09.2026 und wird getrennt entschieden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 19:02:05 +02:00
DogFatherGitandClaude Opus 5 ae2f12c67d "Fuer wen"-Reihe weg -- aber nur bei Team Dogi
Filipe, mit Bildschirmfoto der Leiste "FUER WEN · Alle 3 2 ·
Marina 1 · Diene 0": "brauchen wir nicht auf der aufgaben seite."

GETRENNT GEFUEHRT, wie seit dem 24.09. fuer jeden Umbau. Es ist
DIESELBE Reihe in beiden Haeusern, aber nicht dieselbe Aufgabe:

  Team Dogi    -> "Fuer wen", darin drei, vier Modis, die ohnehin
                  alle auf einem Schirm stehen. Ein Filter fuer eine
                  Liste, die kuerzer ist als er. WEG.
  Spicy Media  -> "Alle Creator" / "Deine Creator". Scouts und
                  Manager filtern damit durch ihre Creator, und das
                  sind deutlich mehr. BLEIBT.

Die Trennung ist eine einzige Zeile an `ich.haus` -- das kommt vom
Server. Eine Rollenliste im Browser waere die zweite Wahrheit und fuer
DogFather, der in beiden Haeusern arbeitet, sogar falsch.

Der Zweig fuer die dritte Beschriftung ist mit entfernt, nicht
stehengelassen: "Fuer wen" ist von nirgends mehr erreichbar, und ein
Zweig, den niemand erreicht, sieht beim Lesen aus wie ein Fall, den
es gibt -- beim naechsten Umbau pflegt ihn jemand mit.

BEWIESEN IN BEIDE RICHTUNGEN, sonst waere die Trennung eine
Behauptung:

  * pruef-handy-teamdogi (neu, 3 Pruefungen, 244 -> 247): auf crew.
    im echten Browser ist die Reihe weg -- MIT der Gegenprobe, dass
    die Seite ueberhaupt steht (4 Knoepfe in der Schwester-Reihe).
    "Reihe nicht da" waere sonst auch auf einer kaputten Seite wahr.
  * pruef-aufgabenbrett (49, unveraendert): bei Spicy Media steht sie
    weiterhin da, mit "Alle, Tili, Luna" und der Beschriftung "Alle
    Creator".

ZWEI DINGE AM PRUEFWERKZEUG, die mich heute Zeit gekostet haben:

1. `probleme` wurde seit jeher gefuellt und NIE ausgegeben. Am Ende
   stand "1 Befund" und sonst nichts -- wer das liest, weiss DASS
   etwas ist, nicht WAS, und muss den ganzen Lauf noch einmal
   anstossen. Die Befunde stehen jetzt unter der Zahl.

2. Der Anmeldeweg stand in der Schleife, und fuer die neue Messung
   habe ich ihn abgeschrieben -- dabei eine aeltere Fassung erwischt,
   ohne `isVisible()` und mit `click()` statt `check()`. Ergebnis:
   30 Sekunden Zeitablauf an der Altersfrage, die es fuer DogFather
   gar nicht gibt. Jetzt ein Helfer, den beide Aufrufer benutzen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:25:26 +02:00
DogFatherGitandClaude Opus 5 7d56837049 Fuenfte Etappe: "abgebrochen" fehlte in der Pruefung
Die Live-Datenbank hat mir den Fehler gezeigt, nicht der Quelltext.
Nach dem Ausliefern ein Blick auf die echten Staende: erledigt 6,
offen 4 -- und abgebrochen 2. Die zwei standen in meiner Pruefung
nicht.

GRUND: Ich hatte die Etappen aus STATUS in workspace-aufgaben.js
abgeleitet, und dort stehen nur vier. "abgebrochen" fehlt dort
ABSICHTLICH, damit der normale Weg es nicht setzen kann -- es kommt
ueber eine eigene Route mit Pflichtbegruendung. Wer die Liste aus
STATUS ableitet, uebersieht also ausgerechnet den Endzustand, in dem
Aufgaben am laengsten liegen bleiben. Dieselbe Luecke hat in dieser
Datei schon einmal zwei Bedingungen erwischt, die nur gegen
'erledigt' prueften.

"In jeder etape" waere damit eine Zusage ueber vier von fuenf
Etappen gewesen.

Zwei Dinge dafuer nachgezogen:

  * Die Etappe wird jetzt aus der DATENBANK gelesen, nicht aus der
    Liste. Abgebrochene Aufgaben werden an mehreren Stellen bewusst
    ausgeblendet -- "Etappe steht wirklich" waere ueber die Liste
    gar nicht messbar gewesen.
  * Die Auskunft an die Oberflaeche wird nur verlangt, WENN die
    Aufgabe in ihrer Liste steht. Blendet das Haus eine Etappe aus,
    gibt es dort keinen Knopf zu zeigen; dann zaehlt allein der
    Server, und den misst der DELETE. Gemessen: Sie steht drin, und
    der Knopf wird auch dort angeboten.

pruef-verteilen 42 -> 44, jetzt 5/5 in allen Zaehlungen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:01:44 +02:00
DogFatherGitandClaude Opus 5 95d68fd392 Rechte Hand und DogFather duerfen Aufgaben immer loeschen, in jeder Etappe
Filipe: "ich will noch dass die rechte hand und dogfather bei den
aufgaben immer die option haben aufgaben zu loeschen. die soll es
immer geben in jeder etape."

NACHGEMESSEN STATT ANGENOMMEN, was vorher galt (darfAufgabenVerteilen
&& darfAendern):

  DogFather (admin)  -> Leitung, konnte schon immer ueberall loeschen.
  rechte Hand (hand) -> NUR an Aufgaben, bei denen sie selbst
                        Erstellerin, Verantwortliche oder Zielperson
                        war. An allen anderen fehlte der Knopf.

Die Luecke war also allein die rechte Hand -- und sie hing nicht an
der Etappe, sondern an der Zugehoerigkeit. Genau das faellt weg.

ZUR ETAPPE: Der Loeschweg hat auch vorher keinen Status geprueft, und
die Oberflaeche zeigt den Stift an JEDER Karte. Es gab hier nichts
freizuschalten -- die Zusage "in jeder etape" ist jetzt aber
festgenagelt, damit sie niemand mit einer gut gemeinten
Statusbedingung wieder aufhebt.

`istRechteHand` NEU, UND NICHT `istHand`: Letzteres umfasst auch die
LINKE Hand. Haette ich das genommen, haette sie das Recht lautlos
mitbekommen -- im ganzen Haus hat sie durchgaengig das kleinere
Recht, und es waren zwei Rollen genannt, nicht drei. Der Vergleich
`rolle === "hand"` stand bisher VIERMAL verstreut im Haus
(workspace-chat zweimal, workspace-material, workspace-rechte); eine
fuenfte Abschrift waere die naechste, die irgendwann abweicht. Die
vier alten bleiben vorerst unberuehrt -- nebenbei umgebaut waeren sie
vier ungepruefte Rechteaenderungen.

pruef-verteilen 30 -> 42. Darin steht jetzt das GEGENTEIL einer
bisherigen Zeile: "Gegenprobe: eine fremde Aufgabe loescht sie nicht
(403)" war am 25.09. richtig, als der Auftrag "ihre eigenen" hiess.
Sie ist mitgewandert statt stehenzubleiben -- eine Pruefung, die eine
zurueckgenommene Regel weiter verteidigt, haelt die Aenderung auf und
sieht dabei aus wie Sorgfalt.

Alle vier Etappen einzeln (offen, arbeit, review, erledigt), jeweils
mit der Kontrolle, dass die Etappe wirklich steht -- sonst waeren es
in Wahrheit viermal "offen" und die Pruefung gruen, ohne je etwas
anderes gesehen zu haben. Dazu je Etappe die Auskunft an die
Oberflaeche (darf_loeschen), der echte DELETE und ein Blick in die
Datenbank.

GEGENPROBE GEFAHREN: Mit `istHand` statt `istRechteHand` meldet die
Pruefung "die LINKE Hand darf es in keiner (0/4 mal abgelehnt)",
2 Fehler. Sie faengt also genau den Fehler, der am teuersten gewesen
waere.

Gegengemessen: pruef-haerte 20, pruef-aufgabenbrett 49,
pruef-rechtetafel 19, pruef-zuteilung 98, pruef-haus-trennung 100 --
alle 0 Fehler. Das andere Haus ist unberuehrt; "hand" gibt es dort
nicht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 15:00:03 +02:00
DogFatherGitandClaude Opus 5 e00a905ce8 Dopplung weg: "Bei dir klingelt nichts" stand zweimal auf einem Schirm
Der neue Kasten von heute Mittag hat die alte Tagesblick-Zeile nicht
ersetzt, sondern sich darueber gesetzt. Beide sagten dasselbe,
untereinander, auf derselben Seite.

GEFUNDEN HAT DAS NUR EIN BILDSCHIRMFOTO. Beide Teile waren fuer sich
gruen: pruef-glocke verlangte die Zeile, pruef-push-hinweis den
Kasten, und keine der beiden konnte sehen, dass die andere existiert.
Zahlen zeigen so etwas nie. Deshalb macht pruef-push-hinweis jetzt
bei jedem Lauf ein Foto -- und scrollt vorher hin, weil der erste
Versuch brav die Begruessungskachel zeigte und den Kasten gar nicht
im Bild hatte. Ein Beweisbild ohne das, was es beweisen soll, ist
schlimmer als keins: Man glaubt ja, hingesehen zu haben.

Entfernt wurde die aeltere der beiden (19.09.2026). Sie war gut
gebaut, server-seitig und nicht wegklickbar -- aber sie hat das
Problem nicht geloest: Am 03.10. nachgemessen hatten immer noch
12 von 20 kein Geraet, darunter die linke Hand und ein Modi. Sie SAGT
es und man kann nichts tun; sie fuehrt nur auf eine andere Seite. Der
neue Kasten hat einen Knopf und erklaert den iPhone-Fall, in dem
Web-Push ohne Home-Bildschirm gar nicht geht.

Entscheidung von Filipe am 03.10. Offen gesagt, was damit aufgegeben
ist: Wer "Nicht jetzt" tippt, bekommt keine zweite Erinnerung mehr.
Gegensicherung ist die Personenliste -- dort steht seit heute bei
jeder Person, die nichts bekommt, genau das.

pruef-glocke 36 -> 33, und die Zahl ist erklaert: 5 Pruefungen zur
alten Zeile entfernt, 2 neue dafuer. Die neuen pruefen das
GEGENTEIL von vorher -- dass die Zeile nicht unbemerkt
zurueckkommt -- mit der Zahl der geprueften Hinweise in der
Bedingung, damit eine leere Antwort der Schnittstelle nicht als
"steht nicht drin" durchgeht.

Die alte Gegenprobe ("mit angemeldetem Geraet ist der Hinweis weg")
ist ersatzlos entfallen, nicht aus Bequemlichkeit: Sie waere ab jetzt
IMMER gruen, egal was das Haus tut. Eine Pruefung, die nichts mehr
widerlegen kann, taeuscht nur Deckung vor. Was sie geprueft hat,
prueft pruef-push-hinweis am neuen Kasten, mit einer echten
Anmeldung.

OHNE_ZAHL in start.js bleibt als leere Liste stehen -- der naechste
Zustandshinweis braucht sie wieder, und als Liste ist sie die eine
Stelle dafuer.

Gegengemessen: pruef-tagesblick 23, pruef-start-ansicht 160,
pruef-push-hinweis 16 -- alle 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:47:50 +02:00
DogFatherGitandClaude Opus 5 cc716a9c1c Startseite sagt einmalig Bescheid, wenn jemand nichts bekommt
Am 03.10.2026 nachgemessen: 20 aktive Personen, 8 mit einer
Anmeldung. Zwoelf bekamen keine einzige Benachrichtigung -- darunter
die linke Hand und ein Modi. Die heute frueh behobene Dringlichkeit
hilft diesen zwoelf nichts: Ohne Anmeldung geht gar nichts hinaus.

Belegt im neuen Sendeprotokoll, seit dem Deploy heute Mittag:
10 Chat-Meldungen zugestellt, 26 Versuche an "keine_geraete"
gescheitert. Genau diese 26 sind der Grund.

Keiner der zwoelf wusste es. Die Glocke sagt es nur dem, der sie
anschaut -- und genau das hatten sie nie getan. Jetzt steht auf der
Startseite eine ruhige Zeile mit zwei Knoepfen: Anschalten oder
nicht jetzt.

ABGELEITET AUS `lage()`, der Stelle, die es ohnehin weiss. Eine
zweite Ableitung daneben waere die, die beim naechsten Umbau etwas
anderes behauptet als die Glocke zwei Zentimeter weiter oben.

NUR DORT, WO ES ETWAS ZU AENDERN GIBT. Bei "verboten" hilft kein
Knopf (das muss man im Browser zuruecknehmen), bei "geht-nicht"
erst recht nicht. Auf dem iPhone erklaert er stattdessen den Weg
ueber den Home-Bildschirm -- ohne den gibt es dort gar kein
Web-Push, und das weiss sonst niemand.

NUR AUF DER STARTSEITE, obwohl glocke.js auf 39 Seiten laeuft: Die
Seite stellt den Platz, das Skript fuellt ihn -- dasselbe Muster wie
`#glocke-platz`. Auf jeder Seite waere er nach dem zweiten Mal
Tapete.

Ruhig, nicht alarmierend: Es ist kein Fehler, sondern eine
Einstellung, die noch niemand getroffen hat. Rot waere hier falsch.

pruef-push-hinweis.mjs (neu, 16 Pruefungen). Sie misst VIER
Abwesenheiten und nur eine Anwesenheit, weil die Gefahr auf der
anderen Seite liegt: weg nach "Nicht jetzt", weg geblieben nach dem
Neuladen, nicht da auf anderen Seiten (mit der Gegenprobe, dass die
Glocke dort sehr wohl steht -- sonst waere nur gemessen, dass das
Skript gar nicht laeuft), und nicht da, wenn die Benachrichtigungen
AN sind. Die letzte mit echter Anmeldung, die nachweislich in der
Datenbank landet.

Dabei gelernt, und es stand nicht im Code: `browser.newContext()`
gibt ein Inkognito-Fenster, und Chrome unterstuetzt dort die Push-API
nicht -- "deliberately no way to feature-detect this". Die Pruefung
meldete zuerst den dritten Ausgang statt gruen, und weil sie die
Browsermeldungen mitschreibt, stand die Ursache sofort da. Jetzt
laeuft sie mit einem echten Profil.

Gegengemessen, dass die zusaetzliche Zeile nichts verschiebt:
pruef-glocke 36, pruef-start-ansicht 160, pruef-tagesblick 23,
pruef-kachelraster 24, pruef-handy 189, pruef-breiten 23,
pruef-lesbarkeit 14, pruef-ueberlappung (20 Breitenpaare) -- alle
0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:36:51 +02:00
DogFatherGitandClaude Opus 5 628b99943e Personenliste zeigt, wen eine Benachrichtigung ueberhaupt erreicht
Am 03.10.2026 am laufenden System nachgemessen: 20 aktive Personen,
aber nur 8 mit einer Push-Anmeldung. Zwoelf bekommen keine einzige
Benachrichtigung -- darunter ein Modi und die linke Hand.

Das war fuer niemanden sichtbar. Die Glocke sagt es nur dem, der sie
anschaut, und wer etwas Wichtiges schreibt, konnte nicht wissen, wen
es erreicht. Die Dringlichkeit, die heute frueh behoben wurde, hilft
diesen zwoelf gar nichts: Ohne Anmeldung geht nichts hinaus.

Der Hinweis steht jetzt auf der Personenseite, also dort, wo ohnehin
ueber Personen entschieden wird -- nicht auf einer eigenen Seite, die
niemand aufruft. Aus DERSELBEN Abfrage wie alles andere; eine zweite
waere eine zweite Gelegenheit, dass die Liste etwas anderes sagt als
die Wirklichkeit.

NUR BEIM FEHLEN, UND DAS IST ABSICHT. Stuende bei jeder Person eine
Zeile, stuenden bei zwanzig Personen zwanzig Zeilen da, und die, auf
die es ankommt, gingen darin unter -- eine Angabe, die immer kommt,
wird nicht mehr gelesen. Schweigen heisst hier: erreichbar. Die
Zeilen verschwinden eine nach der anderen, sobald jemand die Glocke
anschaltet.

Nicht bei gesperrten Personen: Dort ist es keine Luecke, sondern
richtig so.

pruef-personen-liste 33 -> 37, mit der Gegenprobe in beide
Richtungen: Eine Person bekommt im Testbestand ein angemeldetes
Geraet, und bei genau ihr darf der Hinweis NICHT stehen. Stuende er
ueberall, waere "er steht da" wertlos.

Gegengemessen, dass die zusaetzliche Zeile nichts verschiebt:
pruef-personen-kachel 45, pruef-hand-personen 49,
pruef-modi-verborgen 94, pruef-betreuung 18, pruef-scout-zuteilung
(liest dieselbe Klasse .person__bezug) -- alle ohne Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:09:51 +02:00
DogFatherGitandClaude Opus 5 e349de0040 Sendeprotokoll fuer Benachrichtigungen: "ich bekomme nichts" ist jetzt beantwortbar
Beim Nachsehen zu Dienes Meldung ist aufgefallen, dass ueber den
Versand selbst nichts festgehalten wird. Im Protokoll standen nur
push_angemeldet und push_abgemeldet -- kein Wort darueber, ob je
etwas verschickt wurde, an wie viele Geraete, und warum nicht.

Von neun Aufrufstellen wertete genau eine den Rueckgabewert aus; die
anderen acht warfen ihn weg. Sagt jemand "ich bekomme nichts", liess
sich also nicht nachsehen, OB gesendet wurde -- dieselbe Falle wie am
06.09. in VanVans Shop, wo zwei Stunden in die Zustellung ermittelt
wurden, bevor jemand fragte, ob die Mails abgeschickt waren. Sie
waren es, alle, nachweisbar in einer Abfrage.

zuletzt_ok reicht dafuer nicht: Es sagt, wann zuletzt irgendetwas
ankam -- nicht was, nicht an wen sonst, und nichts ueber die Faelle,
in denen gar nicht erst gesendet wurde. Genau die (abgeschaltet,
Ruhezeit, kein Geraet) sind die haeufigste Antwort auf die Frage.

Neue Tabelle push_versand: Zeit, Person, Art, Grund, Geraete,
zugestellt. Festgehalten wird JEDER Ausgang, auch der, bei dem nichts
hinausging -- ein Protokoll, das nur Erfolge kennt, kann die Frage
nicht beantworten, fuer die es angelegt wurde.

Das Protokoll sitzt als Huelle um den Versand, nicht in ihm: Sieben
Ausgaenge einzeln zu protokollieren waere eine Liste zum Pflegen, und
der achte, den jemand naechstes Jahr einbaut, umginge sie still --
so sind am 11.09. die Spalten beim Tabellenumbau verschwunden. So
steht ein neuer Ausgang ohne Zutun mit drin.

Aufraeumen nach 30 Tagen, neben dem bestehenden Aufraeumen von
push_verschickt. Gemessen statt geschaetzt: rund 16 Chat-Nachrichten
am Tag an bis zu acht angemeldete Geraete -- ein paar tausend Zeilen
im Monat.

Das Schreiben kann den Versand nicht aufhalten (try), meldet sich
aber, wenn es scheitert: ein Protokoll, das heimlich nichts
schreibt, ist schlimmer als keins.

pruef-push-eilig 13 -> 20. Darunter die Gegenprobe, dass auch das
NICHT-Senden protokolliert wird, und dass ein Fehlschlag nicht als
zugestellt gilt.

Datenbank vorher gesichert und die Sicherung geprueft (integrity_check,
20 Personen, 10 Anmeldungen lesbar).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 13:03:29 +02:00
DogFatherGitandClaude Opus 5 73f9b24793 Benachrichtigungen wecken das Telefon wieder: Urgency je Art
Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."

Alles Messbare war gruen: Der Service Worker zeigt die Meldung bei
geschlossener App (pruef-push-zu, 13 Pruefungen), jede Anmeldung im
Haus stand auf fehler = 0, der Push-Dienst quittierte mit 201. Nur
den Kopf auf der Leitung hat nie jemand gemessen:

    Urgency: normal

Beide Dienste behandeln das ausdruecklich als "darf warten".
Android/FCM haelt solche Meldungen zurueck, solange das Telefon
doest, und stellt sie zu, wenn es aufwacht -- typischerweise beim
Entsperren oder Oeffnen der App. Apple/APNs nennt es "verzoegert,
gebuendelt oder gedrosselt". Das ist Dienes Satz, Wort fuer Wort,
und es stand als Vorgabe im eigenen Quelltext.

zuletzt_ok konnte das nie zeigen: Es beweist, dass der DIENST
angenommen hat, nicht dass das GERAET etwas angezeigt hat.

Bis hierher hing die Dringlichkeit an der Ruheregel (dringend =
regel === "nie"), mit der Begruendung, "darf das nachts stoeren?"
und "darf das warten?" haetten dieselbe Antwort. Haben sie nicht:
Ein Chat um 3 Uhr soll schweigen, um 14 Uhr aber nicht vierzig
Minuten liegen bleiben. Jetzt zwei Felder -- in DERSELBEN Zeile
derselben Artenliste, damit sie nicht auseinanderlaufen koennen.

10 von 19 Arten sind eilig (Anruf, Chat, Erwaehnung, Support, Hilfe,
Termin, Wecker, zweimal Live, Probe). Die anderen neun duerfen
warten -- waere alles eilig, waere nichts mehr eilig.

DIE FALLE BEIM BEHEBEN: dringend steuerte auch die Haltbarkeit
(TTL 150). Ein einfach auf "dringend" gestellter Chat waere nach
150 s verfallen -- wer sein Telefon drei Minuten aus hat, haette die
Nachricht GAR nicht mehr bekommen. Aus "zu spaet" waere "nie"
geworden. Deshalb sind eilig und kurzlebig getrennt; kurzlebig
bleibt genau beim Anruf und der Probe.

Der alte Name kracht jetzt, statt still "normal" zu liefern.

pruef-push-eilig.mjs (neu, 13 Pruefungen): die Entscheidung je Art
unabhaengig notiert statt aus der Artenliste abgelesen, jede Art
muss eine Entscheidung haben, und der Kopf wird durch
benachrichtige() hindurch an einem nachgebauten Push-Dienst
gemessen. Gegenprobe gelaufen: ohne das Feld meldet sie
"chat_nachricht geht mit Urgency: normal hinaus", 4 Fehler.
Die Uhr ist dort eine Eingabe, keine Annahme -- die Zeitzone wird so
gewaehlt, dass der Lauf immer auf 12 Uhr faellt, sonst waere die
Pruefung nachts rot ohne Befund.

pruef-anruf-klingelt 25 -> 26. Dabei aufgefallen: Eine ihrer
Pruefungen war gruen, obwohl die Zeile aus dem Code verschwunden war
-- sie stand nur noch in einem Kommentar, der die alte Fassung
zitiert. Quelltextpruefungen lesen dort jetzt ohne Kommentare.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:58:01 +02:00
DogFatherGitandClaude Opus 5 db8dd2fbea 147 Zeichen je Zeile -- am Kontrast lag es nicht
Filipe: „nur die kacheln noch viel geiler. so dass ich die texte auch
besser erkenne."

NAHELIEGEND WAERE GEWESEN, die Farben aufzuhellen. Erst gemessen,
dann gebaut -- und die Vermutung war falsch:

    Kontrast .s-karte__text        15,7 : 1   (noetig 4,5)
    Kontrast aller zehn Textarten   7,5 bis 15,7 : 1
    ZEICHEN JE ZEILE                        147   (angenehm 45-75)

Am Kontrast lag es nicht; der ist ueberall ueppig. Es lag an der
ZEILENLAENGE. Auf 1280 px lief der Meldungstext ueber die ganze
Kartenbreite: 1120 px, knapp 150 Zeichen. Beim Zeilenwechsel findet
das Auge die naechste Zeile nicht mehr sicher -- man liest dieselbe
zweimal oder ueberspringt eine. Das ist der groesste Hebel beim
Lesen, und er kostet nichts.

GEAENDERT

    Meldungstext   15,2 -> 17 px,  max-width 72ch,  Zeilenhoehe 1.6
    Verlaufstext   14,4 -> 15,2 px, max-width 70ch
    Kartenrand     16/18/15/22 -> 18/20/16/24 px
    Trennlinie     zwischen Meldung und Verlauf

    147 -> 78 Zeichen je Zeile (PC), 39 auf dem Handy

`ch` UND NICHT PIXEL: `ch` ist die Breite der Null und waechst mit
der Schrift mit. Eine Angabe in Pixeln waere bei der naechsten
Schriftgroesse wieder falsch -- dieselbe Falle wie jede feste Zahl.

MEHR RAND, WEIL DIE SCHRIFT GEWACHSEN IST. Bei unveraendertem Rand
haette sie die Kante beruehrt, und die Karte waere voller gewirkt
statt lesbarer. Links bleibt es breiter: dort laeuft die
Leuchtschiene in der Farbe des Stands.

DIE TRENNLINIE kostet einen Pixel und beantwortet „wo hoert die
Meldung auf und wo faengt die Vorgeschichte an?". Vorher lief beides
ohne Zaesur ineinander.

EIN FEHLER IN MEINER EIGENEN MESSUNG -- und er haette Schaden
angerichtet

Die erste Fassung der Kontrastrechnung meldete fuer drei gut lesbare
Texte einen Kontrast von 1,04. Ich war nah dran, Farben zu
„reparieren", die in Ordnung sind.

Der Grund: `rgb()` zaehlt 0..255, `color(srgb …)` zaehlt 0..1 -- und
genau das liefert Chrome fuer jedes `color-mix()`. 0,77 als 0,77/255
gelesen ist fast Schwarz. Aufgefallen ist es nur, weil die Zahl
nicht zum Bildschirmfoto passte: Dort waren die Texte deutlich
lesbar.

DESHALB STEHT IN DER PRUEFUNG EINE GEGENPROBE IM BROWSER: Ein
absichtlich zu blasser Absatz wird eingehaengt und MUSS auffallen
(gemessen 1,23 : 1). Ohne sie waere „alle ueber 4,5" auch dann
gruen, wenn die Rechnung kaputt ist -- und genau das war sie.

GEPRUEFT -- pruef-support-bilder 56 -> 64 ok

    9 Textarten in der Karte gemessen
    der Meldungstext ist 16.96 px gross (mindestens 16)
    und bricht nach etwa 78 Zeichen um (hoechstens 85, vorher 147)
    der Verlaufstext: 15.2 px, etwa 75 Zeichen
    jeder Text haelt die Kontrastschwelle (0 darunter)
      — schwaechster 7.55 : 1
    Gegenprobe: ein absichtlich blasser Text faellt auf (1.23 : 1)
    auf 390 px ragt mit der groesseren Schrift nichts heraus (0 px)
    und die Zeile bleibt lesbar lang (etwa 39 Zeichen)

Dazu gruen: pruef-css-klassen · pruef-fingermass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:37:05 +02:00
DogFatherGitandClaude Opus 5 a1c2d49260 Drei Bilder konnte man schon -- nur sagte es niemand
Filipes Frage: „koennen die leute auch schon mehrere fotos schicken
wenn die im support was melden wollen?"

Technisch seit dem 02.10. (VanVans Meldung #11), und heute mehrfach
am laufenden Server nachgemessen. Beim Nachsehen auf der SEITE stand
aber:

    Knopf:       „Bild anhängen"          -- Einzahl
    Unterzeile:  „häng ein Bild dran"     -- Einzahl
    daneben:     (nichts)

Wer nicht zufaellig ein zweites Mal auf den Knopf tippt, schickt
eines. Aus seiner Sicht waere VanVans Meldung ungeloest -- und er
haette recht.

EINE MOEGLICHKEIT, VON DER MAN NICHTS WEISS, GIBT ES NICHT. Das ist
dieselbe Sorte wie ein Knopf, der eine Absage holt, nur andersherum:
Dort verspricht die Oberflaeche zu viel, hier zu wenig. Beides
kostet denselben Menschen dieselbe Zeit.

GEAENDERT

    Knopf:       „Bilder anhängen"
    Unterzeile:  „häng Bilder dran"
    daneben:     „bis zu 3 Bilder"        -- bevor man etwas waehlt
    nach einem:  „eins.png · 24 KB · noch 2 möglich"

Die letzte Zeile ist die wichtigere von beiden: „eins.png · 24 KB"
allein sagt nicht, dass ein zweites geht -- und genau an dieser
Stelle hoert jemand auf.

DIE ZAHL KOMMT VOM SERVER (`bilder_max`) und wird neu geschrieben,
sobald sie da ist. Ohne das stuende beim ersten Laden der
Vorgabewert dort -- heute zufaellig derselbe, morgen vielleicht
nicht. Eine Zahl, die zufaellig stimmt, ist keine Auskunft.

GEPRUEFT -- pruef-support-bilder 52 -> 56 ok

    der Knopf steht in der Mehrzahl („Bilder anhängen")
    und daneben steht, wie viele gehen („bis zu 3 Bilder")
    auch die Unterzeile sagt nicht mehr „ein Bild"
    und daneben steht, dass noch Platz ist
      („eins.png · 0 KB · noch 2 möglich")

Dazu gruen: pruef-zeichen 7 · pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 12:24:50 +02:00
DogFatherGitandClaude Opus 5 ae5b467e9a Der Supportverlauf: eine Zeitachse, eine Farbe je Runde
Filipe: „ich will diese komplette seite vom support viel
uebersichtlicher, profissioneller, jede runde soll auch immer eine
spezielle farbe haben und anders aufgestellt aufgeteilt sein damit
man einen viel besseren und krasseren ueberblick hat."

WAS VORHER DASTAND -- an einem Bild mit zwei Runden nachgesehen,
bevor eine Zeile angefasst wurde: vier Textzeilen untereinander,
alle in derselben Farbe. „RUNDE 1", Antwort, „Ging noch nicht · …",
„RUNDE 2". Bei fuenf Runden muss man die Runden ZAEHLEN, statt sie
zu sehen -- und wer spricht, stand nur als kleiner Name daneben.

VIER TEILE SIND NEU

 1. EINE LEISTE OBEN. Ein Punkt je Runde, in ihrer Farbe, mit ihrer
    Nummer -- und der Rand sagt, wie sie ausgegangen ist. Damit ist
    „wie oft ging es schon hin und her?" eine Frage des Hinsehens.
    Erst ab zwei Runden: bei einer waere sie ein Punkt neben nichts.

 2. EINE ZEITACHSE statt einer Liste. Jede Runde haengt mit einem
    Punkt an einer Linie in IHRER Farbe. Die Linie ist ein Rand an
    der Runde selbst, nicht am Behaelter -- so traegt jede ihr
    eigenes Stueck Achse.

 3. ZWEI SPRECHRICHTUNGEN je Runde, als getrennte Kaesten:
    „GEANTWORTET" in der Rundenfarbe, darunter „GING NOCH NICHT"
    (Bernstein) oder „GEHT WIEDER" (gruen) oder „WARTET AUF
    ANTWORT". Vorher standen beide Saetze als Absaetze untereinander
    und sahen gleich aus; man musste lesen, um zu wissen, von wem
    sie sind. Jetzt sagt es die Form.

 4. DIE LETZTEN ZWEI OFFEN, aeltere hinter „3 fruehere Runden
    zeigen". Die Leiste sagt ohnehin, wie viele es gab; im Wortlaut
    braucht es nicht alle. Zwei und nicht eine: Die letzte sagt, wo
    es steht, die vorletzte, woran es davor lag.

DIE FARBE WIRD GERECHNET, NICHT GEPFLEGT

`rundenTon(nr)` gibt 200 + ((nr-1) mod 4) * 32 Grad. Eine Liste mit
fuenf Farben waere die naheliegende Loesung und die falsche: Runde
sechs bekaeme keine. Diese Sorte Liste hat im Haus schon dreimal
etwas gekostet.

DER BEREICH IST ENG UND ABSICHTLICH: 200 bis 296 Grad, Blau ueber
Indigo nach Violett -- die Palette der Seite (babyblau mit lila) und
der Bereich, in dem KEINE Farbe nach „gut" oder „schlecht" aussieht.
Gruen und Bernstein sind fuer den AUSGANG reserviert; waere eine
Rundenfarbe rot, stuenden zwei Aussagen in einer Farbe.

AUGENSCHONEND: 52 % Saettigung, 64 % Helligkeit -- fuer alle Toene
gleich, und nur im CSS. Sonst waere Runde drei blasser als Runde
eins, und augenschonend ist das Gegenteil von zufaellig. Kein Neon,
kein Gluehen. Die Farbe ordnet Zeilen zu; sie schreit nicht.

UND DIE FARBE ALLEIN TRAEGT NICHTS: Die Nummer steht daneben, der
Ausgang in Worten. Fuer Vorleseprogramme gibt es statt elf Punkten
einen Satz.

EIN FUND UNTERWEGS: DIE MESSUNG MASS SEIT ZWEI TAGEN NICHTS

`mess-support-runde.mjs` schickte die Rueckmeldung noch als JSON.
Am 02.10. ist die Route auf den Kopf-Weg umgestellt worden
(`x-geht`, `x-text`); seither antwortete sie mit 400. Gemerkt hat es
niemand, weil diese Datei Bilder macht und keine Rueckgabewerte
prueft: Auf dem Bild stand danach EINE Runde statt vier, und das
sieht aus wie ein Ergebnis. Aufgefallen ist es erst, als die Bilder
vier Rundenfarben zeigen sollten.

Eine Messung, die nach einem Umbau stillschweigend etwas anderes
misst, ist schlimmer als keine -- man glaubt ihr. Sie meldet den
Fehlschlag jetzt mit Code und Text.

GEPRUEFT -- pruef-support-bilder 39 -> 52 ok

    die Leiste zeigt jede Runde (5 Punkte) · mit ihrer Nummer
    die ersten vier Runden haben vier verschiedene Farben (4)
    und die fuenfte faengt die Reihe wieder von vorn an
    Gegenprobe: sie sind nicht alle gleich (4 Farben)
    auch die Rundennummern tragen ihre Farbe (2 von 2)
    offen stehen die letzten zwei Runden (2)
    und die aelteren sind einen Griff entfernt
    jede Runde zeigt beide Seiten
    aufgeklappt stehen alle da (5) · und wieder zu
    auf 390 px ragt nichts heraus (0 px)
    bei einer einzigen Runde gibt es keine Leiste (0)

Die Gegenprobe „sie sind nicht alle gleich" ist die wichtigste:
Waere `--ton` nicht angekommen, waeren alle Punkte grau -- und „vier
Punkte" waere gruen gewesen, ohne dass eine Farbe zu sehen ist.
Gemessen wird die ANGEZEIGTE Farbe, nicht die Zahl im Attribut.

Dazu gruen: pruef-support 104 · pruef-css-klassen · pruef-zeichen 7 ·
pruef-fingermass 5.

Eine Schriftgroesse musste nachgebessert werden: .7rem sind 11,2 px,
und unter 11,5 px faengt im Haus die Grenze an, ab der man
zusammenkneift.

NUR DAS AGENTURHAUS: Die Supportseite liegt unter `/workspace`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:54:46 +02:00
DogFatherGitandClaude Opus 5 ad5e758721 922 px auf einem 844-px-Bildschirm -- die halbe Antwort war zu wenig
VanVan im Support, Meldung #15: „Die Modis koennen die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unuebersichtlich."

Heute Nacht habe ich dazu „Verstanden" gebaut: Gelesenes rutscht
hinter eine zugeklappte Zeile. Das ist richtig und war trotzdem nur
die halbe Antwort -- denn UNGELESENE stehen absichtlich offen da,
und davon hat ein Modi im Haus gerade neun.

GEMESSEN STATT GESCHAETZT, an seinem echten Bestand nachgebaut, auf
dem Handy (390 x 844 px):

    Band:            922 px   -- hoeher als das ganze Fenster
    Anteil der Seite: 49 %

Das IST ihr Satz. Mein Umbau loeste es erst NACH einem Griff auf
„Alle verstanden"; bis dahin stand die Wand unveraendert da.

GEAENDERT: Hoechstens drei stehen offen, der Rest ist einen Griff
entfernt („und 6 weitere"). Danach:

    Band:            566 px   -- passt in den Bildschirm
    nach einem Griff: 46 px   (zugeklappte Zeile)

WARUM NICHT NULL: Eine Absage, die man aufklappen muss, ist keine
Nachricht mehr -- das war die Begruendung vom 30.09., und sie gilt.
WARUM NICHT ALLE: siehe oben. Drei ist die Zahl, bei der Kopf,
Zeilen und Sammelknopf unter einem Bildschirm bleiben.

DIE NEUESTEN ZUERST -- der Server sortiert nach `entschieden_am
DESC`. Wer nicht aufklappt, hat die juengsten gesehen.

GEPRUEFT -- pruef-bewerbung-aufgaben 178 -> 184 ok

    mit fuenf Ungelesenen stehen drei offen (3)
      und der Rest ist einen Griff entfernt („und 3 weitere")
      das Band passt in den Bildschirm (436 px bei 1000 px)
    aufgeklappt stehen alle da (6, „Weniger zeigen")
    und wieder zu — der Knopf geht in beide Richtungen
    und die Probezeilen sind wieder weg — der Stand ist wie vorher

Die dritte Zeile ist die eigentliche Aussage: „drei Zeilen" waere
eine Zahl, „passt in den Bildschirm" ist der Zweck. Die letzte raeumt
die vier Probezeilen wieder weg -- ohne sie zaehlten die Pruefungen
darunter („1 aeltere Antwort", „2 aeltere Antworten") sechs statt
zwei und wuerden rot, ohne dass etwas kaputt ist.

Der Knopf geht in BEIDE Richtungen, und auch das steht da: Sonst
waere er ein Einwegschalter, und das faellt erst auf, wenn jemand
zurueckklappen will.

AUSSERDEM AN CASPERLINOS ECHTEN NEUN GEMESSEN (Kopie der Datenbank,
eigener Port, nichts im Live-System): neun Antworten, alle
ungelesen; „Verstanden" nimmt genau eine; „Alle verstanden" den
Rest; nachlesbar bleiben alle neun; ein zweiter Druck zaehlt null;
eine fremde Nummer gibt 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:45:03 +02:00
DogFatherGitandClaude Opus 5 63211252d2 VanVans Satz hatte zwei Haelften -- gemessen war eine
Meldung #11, Runde 2, woertlich:

    „man kann nicht mehrere Bilder zum hinzufuegen AUSWAEHLEN.
     Und wenn man es NACHEINANDER versucht hinzuzufuegen wird das
     Bild immer nur ersetzt"

Das NACHEINANDER stand seit gestern in der Pruefung -- es war die
Haelfte, die ich kaputt gebaut hatte und die mir deshalb im Kopf
war. Das AUSWAEHLEN nicht: drei Dateien in EINEM Griff, so wie der
Dateidialog sie uebergibt, wenn man sie mit gedrueckter Taste
markiert. Das haengt am `multiple` im Feld, und ohne Messung war es
eine Behauptung im HTML.

Aufgefallen beim Durchgehen ihres Satzes Wort fuer Wort, nachdem
Filipe auf den Screenshot gezeigt hat. Kaputt war nichts -- aber
unbewiesen, und das ist derselbe Zustand wie ungeprueft, nur mit
besserem Gefuehl.

NEU GEPRUEFT

    drei auf einen Griff ausgewaehlt ergeben drei (3)
      und zwar in der Reihenfolge des Dialogs
      das Feld laesst Mehrfachauswahl ausdruecklich zu (multiple)

Die letzte Zeile ist die Gegenprobe zur ersten: Ohne `multiple`
gaebe der Browser nur EINE Datei weiter, egal wie viele man
markiert -- und die Drei darueber koennte auch aus drei einzelnen
Griffen stammen. Gemessen wird deshalb das Merkmal selbst.

Davor wird ausdruecklich alles geleert („Alle weg"), sonst misst die
Zeile, was vorher schon dastand.

GEPRUEFT -- pruef-support-bilder 36 -> 39 ok

AUSSERDEM HEUTE AM LAUFENDEN SERVER NACHGEMESSEN (Meldung #11, auf
einer Kopie der echten Datenbank, eigener Port, nichts im
Live-System):

    er nennt die Grenze: 3
    die Meldung geht durch (201) · alle drei haengen dran (3)
    jedes kommt in SEINER Reihenfolge und mit SEINEM Typ zurueck (3/3)
    vier werden abgelehnt, mit einem Satz
    der alte Einzelbild-Weg ist weg (404) — es gibt nur noch einen

Und: Die Dateien, die VanVans Browser laedt, sind Byte fuer Byte die
geprueften -- support.js und support.css haben live dieselbe
Pruefsumme wie hier.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:35:32 +02:00
DogFatherGitandClaude Opus 5 ef911691b7 Der zweite Weg ist weg -- nicht nur sein Knopf
VanVan im Support, Meldung #8: „Wenn man auf ich fange an drueckt
steht dort in Bearbeitung und wenn man auf fertig drueckt dann wird
es zu erledigt. DIE AUFGABE BLEIBT ABER IM STATUS OFFEN STEHEN."

GESTERN HABE ICH DIE HALBE ARBEIT GEMACHT und daneben eine Ausrede
geschrieben. Die zwei Knoepfe kamen weg, und in den Kommentar kam:

    „Der Weg `/mein-stand` bleibt bestehen -- er ist die Schranke,
     falls ihn jemand direkt anspricht."

Eine Route ist keine Schranke gegen sich selbst. Sie setzte
weiterhin NUR `aufgaben_zuteilung.zustand` und liess
`aufgaben.status` stehen -- also genau den Widerspruch, den VanVan
beschrieben hat. Ich hatte ihn unsichtbar gemacht, nicht
abgeschafft: kein Knopf mehr, das Verhalten unveraendert im System.

GEFUNDEN BEIM NACHMESSEN AM LAUFENDEN SERVER, nicht beim Schreiben.
Filipe hat auf den Screenshot gezeigt und gesagt, es sei noch nicht
in Ordnung. Statt meine Pruefungen zu zitieren habe ich die Route
gelesen -- und dort stand es.

NACHGEMESSEN, BEVOR SIE WEGKAM: Kein einziger Aufruf mehr im
ausgelieferten Browsercode (grep ueber alle JS- und HTML-Dateien des
Workspace). Nur zwei Pruefungen benutzten sie.

UND EINE DAVON NICKTE DEN FEHLER AB. In pruef-zuteilung stand:

    Bea setzt "in Bearbeitung" (HTTP 200)
    und danach "erledigt" (HTTP 200)

Zwei gruene Haken ueber genau dem Verhalten, das gemeldet wurde --
weil sie nur den Rueckgabewert ansahen und nie den Aufgabenstatus
daneben. Eine Pruefung, die nur eine Haelfte misst, kann den
Widerspruch gar nicht finden. Jetzt steht dort:

    Bea setzt "in Bearbeitung" (HTTP 200)
      und BEIDES steht auf "in Arbeit" (Aufgabe arbeit,
      Zuteilung arbeit) — das war VanVans Befund
    und danach "erledigt" (HTTP 200)
      und wieder beides (Aufgabe erledigt, Zuteilung erledigt)

WAS JETZT GILT: `PATCH /workspace/api/aufgaben/:id` mit `{ status }`.
Er setzt den Status UND zieht die Zuteilung mit
(`zuteilungenNachStatus`), kennt dieselbe Sperre fuer dauerhafte
Aufgaben und dieselbe Rechtepruefung. Eine Frage, eine Antwort.

ENTFERNT STATT AUSKOMMENTIERT -- dieselbe Entscheidung wie bei
`/vorlagen/hilfe` am 01.09.: Eine Route, die niemand mehr aufruft,
wird beim naechsten Mal fuer lebenden Code gehalten und mitgepflegt.

EIN SCHRECKMOMENT UNTERWEGS, der sich als Messfehler herausstellte:
Nach der Umstellung meldete pruef-bewerbung-aufgaben eine 404 beim
Abhaken -- also der Verdacht, dass eine ZUGETEILTE Aufgabe ueber den
Statusweg gar nicht erreichbar ist und ich gerade etwas kaputt
gemacht haette. Nachgemessen statt geglaubt: Die Aufgabe steht in
ihrer Liste, der PATCH antwortet 200. Die rote Zeile war eine
DRITTE Stelle, die ich beim Umstellen uebersehen hatte und die noch
auf die alte Route zeigte. Zwei Minuten Messung statt einer Stunde
Suche an der falschen Stelle.

GEPRUEFT

  pruef-zuteilung             ok, mit zwei neuen Zeilen, die BEIDE
                              Zustaende messen
    den zweiten Weg (mein-stand) gibt es nicht mehr (404)
  pruef-bewerbung-aufgaben    178 ok
  pruef-struktur              102 ok, 414 -> 413 Routen
  pruef-aufgabenbrett · pruef-modi-katalog 159 ·
  pruef-aufgaben-vorlagen 60

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:31:18 +02:00
DogFatherGitandClaude Opus 5 2270f0de91 Der „Anpassen"-Knopf war nur an EINEM der beiden Bretter geprueft
Beim Nachsehen zu VanVans Meldung #6 aufgefallen, nicht beim Bauen:
Der Knopf haengt an ZWEI Kartenarten -- an den Creator-Vorlagen
(vorlagenbrett.js:459) und am Team-Katalog (:1220). Im Browser
gemessen war nur die erste.

UND DIE ZWEITE IST DIE, DIE VANVAN BENUTZT. Die Creator-Vorlagen
stehen auf „Aufgaben" und richten sich an Betreuer; der Katalog
steht auf „Eure Aufgaben", und dort verteilt die rechte Hand an die
Modis. Genau das war ihre Meldung.

Dass man diese beiden Bretter verwechselt, ist in diesem Haus schon
passiert: In pruef-bewerbung-aufgaben steht es seit dem 30.09. als
Messfehler notiert, „der wie ein Befund aussieht". Diesmal waere es
andersherum gewesen -- ein gruener Haken ueber einem Weg, den
niemand geprueft hat.

GEMESSEN HAT ES NICHTS KAPUTTES GEFUNDEN: Der Katalogzweig
funktioniert. Aber er war unbewiesen, und das ist derselbe Zustand
wie „ungeprueft" -- nur mit besserem Gefuehl.

DIE GEGENPROBE ZUM ANDEREN BRETT STECKT JETZT DRIN. Ein Creator
bekommt den „dauerhaft"-Haken NICHT (geprueft in
pruef-aufgaben-vorlagen), die Leitung MUSS ihn bekommen (geprueft
hier). Zwei Pruefungen, die denselben Unterschied von beiden Seiten
messen -- erst dann ist es eine Regel und nicht ein Zufall.

GEPRUEFT -- pruef-modi-katalog 150 -> 159 ok

  jede Katalogkarte hat einen „Anpassen …"-Knopf (12 von 12)
  das Fenster hat Frist und Anmerkung
  und die Leitung bekommt den „dauerhaft"-Haken
  die Frist der Vorlage steht als Vorgabe drin (2026-10-05,
    fruehestens 2026-10-03)
  mit Haken wird die Frist gesperrt
  die angepasste Aufgabe liegt in ihrer Liste (3 -> 4)
  sie ist wirklich dauerhaft · und hat keine Frist
  die Anmerkung steht als Notiz dabei

  Die letzten drei fragen die DATENBANK, nicht den Bildschirm. Was
  auf dem Brett steht, ist eine Darstellung; was in der Aufgabe
  steht, gilt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 11:04:03 +02:00
DogFatherGitandClaude Opus 5 3d859cbed5 Eine untaugliche Zweitfassung erkennt der Server selbst
NACHGEMESSEN AN DEN SECHS, DIE HEUTE NACHT LIVE ENTSTANDEN SIND --
vier tragen das Loch am Anfang, weil sie gebaut wurden, bevor es
bekannt war:

    387,6 s Zeitachse fuer wenige Sekunden Ton
    164,2 s
    134,1 s
     63,8 s
      9,7 s  (in Ordnung)
      3,3 s  (in Ordnung)

Sie gehoeren dem Dienstbenutzer; von aussen sind sie nicht
wegzuraeumen. Und mit „gibt es schon, dann weiter" waeren sie fuer
immer so geblieben.

EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG und
die falsche: eine Zahl, die jemand hochzaehlen muss, wird beim
naechsten Mal vergessen. Gefragt wird deshalb die DATEI SELBST --
nach dem, was schiefgehen kann: Passt ihre Zeitachse zu der
Tonmenge, die sie traegt? AAC packt 1024 Abtastwerte in einen
Rahmen; Rahmenzahl mal 1024 durch die Abtastrate ist die echte
Laenge. Mehr als anderthalb Sekunden darueber heisst: Loch.

Was nicht passt, wird beim naechsten Nachruestlauf weggeraeumt und
neu gebaut -- einmal, denn danach passt es.

ZWEI FEHLER IN DIESER FUNKTION, BEIDE VON DER GEGENPROBE GEFUNDEN

Und beide waeren still geblieben:

 1. ffprobe GIBT DIE WERTE IN SEINER REIHENFOLGE AUS, nicht in
    meiner: erst `sample_rate`, dann `nb_frames`. Ich hatte
    `[rahmen, rate, dauer]` destrukturiert und damit 48000 Rahmen
    bei 95 Hz gerechnet -- eine „echte Laenge" von einer halben
    Million Sekunden. Die Funktion haette JEDE Datei fuer tauglich
    erklaert, immer. Gelesen wird jetzt mit Namen.

 2. DER DATEINAME FEHLTE IM AUFRUF. ffprobe antwortete „You have to
    specify one input file", der `catch` machte daraus ein
    freundliches „taugt" -- und wieder sagte die Funktion zu allem
    ja.

Das ist zweimal dieselbe Sorte: gruen und wertlos. Gefunden hat es
nicht das Lesen, sondern die Frage „kann sie ueberhaupt NEIN
sagen?". Genau dafuer gibt es die Gegenprobe.

WIE MAN EINE DATEI MIT LOCH NACHSTELLT -- UND WIE NICHT

`-itsoffset 40` war der naheliegende Weg und ergab 2,5 s: Der
MP4-Baukasten rechnet einen reinen Anfangsversatz wieder heraus.
Dasselbe mit `-copyts`, `-output_ts_offset` und `-muxdelay`. Was
bleibt, ist eine Zeitachse, die laenger ist als ihr Ton -- und das
stellt eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die die
Funktion liest, sind dieselben.

GEPRUEFT -- pruef-chat-anhaenge 164 -> 168 ok

  eine Fassung mit zu langer Zeitachse nachgestellt (40,0 s)
  und sie wird als untauglich erkannt
  der Nachruestlauf ersetzt sie (40,0 s → 2,5 s)
  und die neue gilt als tauglich — er wandelt sie nicht ewig weiter

Die letzte Zeile ist die zweite Gegenprobe: Ein Lauf, der bei jedem
Durchgang alles neu wandelt, waere eine Warnung, die immer kommt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 02:04:23 +02:00
DogFatherGitandClaude Opus 5 ff48a0a6ef Das Loch am Anfang der Sprachnachrichten -- und ein halb fertiges Stueck
NACHGEMESSEN AN EINER ECHTEN NACHRICHT AUS DEM HAUS, nachdem die
Zweitfassungen heute Nacht live entstanden waren. `mumqbw6v….webm`
vom 29.09., 46 Pakete:

    Paket 1 bei   0,000 s
    Paket 2 bei  61,116 s
    ...
    Paket 46 bei 63,756 s

2,76 Sekunden Ton, verteilt ueber eine Zeitachse von 63,8 Sekunden.
Dazwischen ein Loch von einer Minute. `MediaRecorder` setzt diesen
Versatz selbst; die Datenbank kennt ihn nicht (dort steht die Angabe
des Absenders: 2910 ms).

WAS DAS FUER DEN HOERER HEISST: Das Abspielgeraet zeigt 1:03,
springt beim Antippen in eine Minute Stille und faengt erst danach
an. Auf einem Handy ueber Mobilfunk sieht genau das aus wie „laedt
die ganze Zeit" -- Miss' Satz aus Runde 5, woertlich.

Das war also nicht nur der Behaelter. Die Umwandlung von heute Nacht
hat das Loch brav mituebernommen: 63,8 s Zeitachse, 32 KB Inhalt.

GEAENDERT: `-af asetpts=N/SR/TB` vergibt die Zeitstempel neu,
fortlaufend ab null. An der echten Datei nachgemessen -- der
Toninhalt bleibt unveraendert (260 KiB dekodiert vorher wie
nachher), nur die Laenge geht von 63,8 s auf 2,76 s. Es wird nichts
abgeschnitten, sondern ein Loch geschlossen.

UND EIN ZWEITER FUND, von der Pruefung und nicht vom Nachdenken

Sie holte die zweite Fassung ab und bekam 44 BYTES. ffmpeg legt die
Zieldatei sofort an und fuellt sie danach -- bei `+faststart`
schreibt es sie am Ende noch einmal um. Die Stelle, die fragt „gibt
es die zweite Fassung?", sah in dieser Zeit eine Datei, die es gibt
und die nichts enthaelt. Wer dann zuhoert, bekommt ein Bruchstueck.

Jetzt entsteht sie unter `….m4a.teil` und wird erst am Ende
umbenannt. Umbenennen im selben Ordner ist EIN Schritt: entweder
vollstaendig da oder gar nicht. Der Bruchstuecknamen endet
ausdruecklich NICHT auf `.m4a`, sonst waere die Luecke nur
umbenannt -- und deshalb muss `-f mp4` dabeistehen, weil ffmpeg das
Format sonst an der Endung waehlt und `.teil` nicht kennt. Auch das
hat die Pruefung gefunden: Der erste Versuch mit Umbenennen erzeugte
gar keine Datei mehr.

WAS DIE PRUEFUNG JETZT MISST -- UND WAS NICHT

Sie vergleicht NICHT mehr die Zeitachsen beider Dateien. Genau das
war mein erster Anlauf, und er haette das Loch durchgewunken: Wenn
es mitwandert, sind beide Zeitachsen gleich lang und alles ist
gruen. Gefragt wird jetzt nach dem TON -- wie viele Bytes
herauskommen, wenn man beide dekodiert (231 KiB zu 231 KiB) -- und
danach, ob die Zeitachse zu dieser Tonmenge passt (2,46 s fuer
2,46 s Ton).

Ein dritter kleiner: `tonBytes` las die Rueckgabe von
`execFileSync`. ffmpeg schreibt seine Zusammenfassung aber auf
stderr -- die Messung meldete fuer beide Dateien 0 KiB. Rot, und zu
Recht: Sie hat nichts gemessen.

WAS BEWUSST OFFEN BLEIBT: Eine Aufnahme, die schon als MP4
hereinkommt (seit dem 02.10. der Normalfall), wird nicht
umgewandelt -- und traegt ein solches Loch, falls sie eines hat,
weiter. Dass sie eines haette, ist nicht gemessen: Seit der
Umstellung gibt es keine einzige neue Aufnahme. AAC noch einmal nach
AAC zu wandeln kostet Qualitaet fuer ein Problem, das ich nicht
beobachtet habe. Faellt es auf, ist die Stelle bekannt.

GEPRUEFT -- pruef-chat-anhaenge 163 -> 164 ok

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 01:55:46 +02:00
DogFatherGitandClaude Opus 5 d70d00cedc Jede Sprachnachricht in einer Fassung, die JEDES Geraet abspielt
Miss im Support, Meldung #12 -- fuenf Runden seit dem 23.09.: „Bei
mir laesst sich die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt
es die ganze Zeit." Bei allen anderen ging es.

GEMESSEN, BEVOR GEBAUT WURDE

  · Alle sechs Sprachnachrichten im Haus sind audio/webm (Opus).
  · Miss' Geraet: iPhone, iOS 18.7, WebKit.
  · Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
    wurde KEINE EINZIGE neue aufgenommen. Ihr Problem betrifft also
    ausschliesslich die sechs alten Dateien -- und jede kuenftige aus
    einem Firefox, der nichts anderes kann.

WAS ICH NICHT MESSEN KONNTE, und das gehoert dazu: Ob WebKit
WebM/Opus abspielen kann, laesst sich auf diesem Rechner nicht
nachsehen -- Playwrights WebKit startet hier nicht (libegl.dll
fehlt). Die Vermutung „iPhones koennen kein WebM" ist begruendet,
aber von mir nicht gemessen.

GENAU DESHALB STELLT DIE LOESUNG DIE FRAGE NICHT. Statt zu raten,
welches Geraet welchen Behaelter kann, legt der Server neben jede
Sprachnachricht eine zweite Fassung in dem Format, bei dem sich seit
zwanzig Jahren alle einig sind: AAC in MP4. Gibt es sie, wird sie
abgespielt -- bei jedem, nicht nur auf iPhones. Kein Geraetename im
Code, keine Liste, die altert.

  helfer-ffmpeg.mjs     ffmpeg finden, umwandeln, drei Ausgaenge
  beim Hochladen        nebenher, nicht davor: Die Nachricht wartet
                        nicht auf ffmpeg
  toeneNachruesten()    das Netz darunter -- fuer die sechs von
                        frueher und fuer den Fall, dass eine
                        Umwandlung einmal nicht geklappt hat
  ?form=mp4             dieselbe Route, dieselben Rechte; eine
                        zweite haette dieselbe Sichtbarkeitspruefung
                        ein zweites Mal gebraucht

ffmpeg IST ABGESPROCHEN INSTALLIERT (Debian 7.1.5, auf Nachfrage
freigegeben). Fehlt es, passiert nichts Schlimmes: Die
Sprachnachricht geht wie bisher im Originalformat hinaus, und es
steht EINMAL eine Zeile im Protokoll -- nicht bei jedem Hochladen.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. `-movflags +faststart` IST NICHT KOSMETIK. Ohne es steht die
    Inhaltsuebersicht einer MP4 am ENDE. Das Abspielgeraet muss dann
    erst bis ans Ende lesen, bevor es anfangen kann -- genau das
    „laedt die ganze Zeit" aus Miss' Runde 5. Geprueft wird die
    Reihenfolge der Kaesten in der Datei, nicht die Zeile im Aufruf.

 2. DAS ORIGINAL BLEIBT LIEGEN. Ohne `?form=mp4` kommt weiter die
    WebM. Sie ist das, was aufgenommen wurde; sie durch eine
    Umrechnung zu ersetzen hiesse, das Original wegzuwerfen.

 3. EIN GEMEINSAMER LOESCHER. Zwei Stellen entfernen Anhaenge
    (Gespraech wegraeumen, Nachricht zuruecknehmen). Beide nahmen
    genau eine Datei -- ab heute waere bei jeder geloeschten
    Sprachnachricht eine m4a liegengeblieben, ohne Zeile, die auf
    sie zeigt. Dieselbe Luecke hatte ich gestern bei den
    Supportbildern gefunden; hier steht sie von Anfang an an EINER
    Stelle.

ZWEI FUNDE DER PRUEFUNG, BEIDE MEINE EIGENEN

 · `anhang_datei` STAND NICHT IN DER ABFRAGE der Nachrichtenliste.
   Die Funktion, die auf der Platte nachsieht, bekam deshalb nichts
   und sagte brav „gibt es keine zweite Fassung" -- fuer JEDE
   Nachricht. Alles war gebaut, nichts kam an. Gefunden hat das die
   Pruefung, nicht das Lesen.
 · Mein erster Anlauf mass die Aufnahme ueber den Knopf -- und die
   ist seit dem 02.10. schon MP4, braucht also gar keine zweite
   Fassung. Die Pruefung wurde rot und hatte recht: Gemessen werden
   muss der Fall, den es bei Miss gibt. Jetzt nimmt sie ausdruecklich
   eine WebM auf.

Dazu zweimal derselbe alte Tritt: ein Gegen-Apostroph in einem
Kommentar INNERHALB eines Template-Literals. Der Server startet dann
gar nicht. Steht jetzt als Warnung an beiden Stellen.

GEPRUEFT -- pruef-chat-anhaenge 146 -> 163 ok

  Eine echte WebM/Opus-Aufnahme (31 972 Bytes) wird hochgeladen; die
  zweite Fassung entsteht von selbst und kommt als audio/mp4 heraus,
  mit „ftyp"-Marke, mit Accept-Ranges. ffprobe sagt: Original Opus,
  Zweitfassung AAC, 2,40 s gegen 2,46 s. Die Inhaltsuebersicht steht
  bei Byte 32, die Tondaten ab 1270 -- also vorne. Beim Loeschen
  gehen beide Dateien. Gegenproben: ein Bild bekommt keine zweite
  Fassung; eine halbierte Laenge waere aufgefallen; das Original
  bleibt unter seiner eigenen Adresse abrufbar.

  Der dritte Ausgang hat einen eigenen Zaehler: Fehlt ffmpeg, steht
  „KONNTE NICHT NACHSEHEN" mit Anleitung da -- nicht gruen und nicht
  rot. Und der Block raeumt hinter sich auf: Mein Probebild liess
  eine spaetere Pruefung rot werden, weil es die Gespraechsliste
  veraendert hatte. Eine Pruefung, die den Stand fuer die naechste
  verschiebt, ist schlimmer als keine.

  Dazu gruen: pruef-struktur 102 (92 Module) · pruef-ports 10 ·
  pruef-gifs 17 · pruef-treffchat 114 · pruef-chat-optik ·
  pruef-aufbewahrung 45.

NUR DAS AGENTURHAUS? NEIN -- der Chat gehoert beiden Haeusern, und
die Aenderung gilt fuer beide gleich. Sie aendert nichts an
Sichtbarkeit oder Rechten: Wer eine Nachricht hoeren darf, hoert sie
jetzt in einem Format, das sein Geraet kann.

OFFEN BLEIBT DIE ANTWORT VON MISS. Ob es bei ihr jetzt laeuft, weiss
nur sie -- deshalb steht ihre Meldung weiter offen und nicht auf
erledigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 01:41:34 +02:00
DogFatherGitandClaude Opus 5 1ce2c3785e Der Hinweis an der Anmeldung sagte seit dem 01.10. das Gegenteil
VanVan im Support, Meldung #5, zweite Runde: „Man kann sich jetzt nur
noch über die richtige Schaltfläche anmelden. Das funktioniert jetzt.
Aber wenn man sich 2x versucht über den falschen Button anzumelden,
dann kommt ein irreführender Text wo steht, dass die Auswahl oben
nicht schuld ist."

SIE HAT RECHT -- UND DER GRUND IST MEINE EIGENE AENDERUNG

Auf der Crew-Wand stand ab dem zweiten Fehlversuch: „Zugangscode
stimmt nicht. Achte auf die Bindestriche -- die Auswahl oben ist
nicht schuld."

Das war am 20.09.2026 richtig. Dort gab es den STILLEN ZUGANG: Die
rechte Hand und die Modis kamen ueber die Codekennung herein, egal
welche Kachel sie antippten. Der Satz „Stimmt die Auswahl oben?"
haette sie in eine Schleife geschickt -- alle Kacheln durchprobieren,
acht Fehlversuche, Adresse gesperrt. Ein Hinweis, der die Sperre
herbeifuehrt, gegen die er helfen soll.

Mit VanVans ERSTER Runde derselben Meldung ist der stille Zugang
weggefallen („es soll fest sein"). Nachgemessen in workspace.js: Die
Kandidaten kommen seither aus `WHERE rolle = ?`, auf beiden Waenden.
Die Kachel entscheidet ueberall -- und der Satz sagte ab diesem Tag
das Gegenteil der Wahrheit, genau an der Stelle, an der jemand
feststeckt.

DAS IST DIE SORTE FEHLER, DIE EIN UMBAU HINTERLAESST: Nicht der neue
Code war falsch, sondern ein Satz drei Dateien weiter, dessen
Voraussetzung er entfernt hat. Er stand sogar ausfuehrlich begruendet
da -- und die Begruendung las sich beim Umbau wie eine Bestaetigung,
weil sie von einem Zustand sprach, den es nicht mehr gab. Gefunden
hat ihn kein Prueflauf, sondern VanVan beim Benutzen.

GEAENDERT: Ein Satz fuer beide Waende. Die Fallunterscheidung nach
Wand ist weg -- es gibt nur noch eine Regel, also auch nur noch eine
Auskunft. Der Hinweis auf die Bindestriche bleibt; er galt nie nur
fuer eine Wand.

  „Zugangscode stimmt nicht. Stimmt die Auswahl oben? Ein Code
   gehört immer zu genau einer davon – und achte auf die
   Bindestriche."

GEPRUEFT -- pruef-modi-verborgen 87 -> 94 ok

  Im echten Browser, beide Waende (crew-index.html und index.html),
  je zweimal mit einem falschen Code:
    · die Waende sind wirklich verschieden (gate--crew true/false)
    · „nicht schuld" steht nirgends mehr
    · stattdessen die Frage nach der Auswahl -- die dort seit dem
      01.10. wirklich gilt (Abschnitt 1 derselben Datei misst das)
    · derselbe Satz auf beiden Waenden
    · Gegenprobe: nach dem ERSTEN Versuch steht er noch nicht da,
      dort steht die kurze Absage. Ein Hinweis, der immer kommt,
      waere keiner.

  DIE VERSUCHSSPERRE WIRD VOR DER MESSUNG GELEERT. Acht Fehlversuche
  je Adresse in zehn Minuten -- die Abschnitte davor verbrauchen
  welche, und ab dem neunten stuende „Zu viele Versuche" statt des
  Satzes. Das waere ein roter Haken ueber die Messumgebung gewesen,
  nicht ueber das Programm.

  Dazu: pruef-struktur 102 ok.

BEIDE HAEUSER BETROFFEN, und das ist hier richtig: Es ist dieselbe
Anmeldung mit derselben Regel. Nachgewiesen wird genau das -- der
Satz ist auf beiden Waenden derselbe, und er stimmt auf beiden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 00:16:05 +02:00
DogFatherGitandClaude Opus 5 f2f11e8091 Gelesene Antworten rutschen zur Seite -- statt sich zu stapeln
VanVan im Support, Meldung #15: „Die Modis können die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unübersichtlich."

WARUM ES NICHT EINFACH EIN SCHALTER GEWORDEN IST

Am 30.09. habe ich diesen Kasten absichtlich NICHT einklappbar
gebaut, und die Begruendung steht woertlich im Quelltext: „Eine
Antwort, die man erst aufklappen muss, ist wieder keine." Sie ist
richtig -- fuer eine Antwort, die man noch nicht gelesen hat. Danach
ist sie falsch herum, und genau das meldet VanVan.

Ein Schalter, der alles wegklappt, haette die naechste Absage
mitversteckt: die Zeile, derentwegen der Kasten ueberhaupt
entstanden ist. Deshalb nicht „einklappbar", sondern GELESEN:

  · Was noch niemand quittiert hat, steht offen da -- wie bisher.
  · „Verstanden" schiebt eine Zeile hinter „N ältere Antworten",
    zugeklappt, jederzeit wieder aufzumachen. Nichts wird geloescht;
    eine Absage samt Begruendung wegzuwerfen, weil jemand sie einmal
    gelesen hat, waere das Gegenteil des Umbaus vom 30.09.
  · Ab zwei offenen Antworten gibt es „Alle N verstanden" -- wer nach
    dem Urlaub sieben vorfindet, soll nicht siebenmal tippen, und
    sieben Anfragen waeren sieben Gelegenheiten, dass eine verloren
    geht.

AM MENSCHEN, NICHT AM GERAET

`gesehen_am` steht in `vorlagen_bewerbungen`, nicht im
Browserspeicher. „Habe ich das gelesen?" ist eine Frage ueber die
Person: Sonst waere dieselbe Antwort auf dem Handy wieder neu,
nachdem man sie am Rechner gelesen hat -- und das waere genau die
Unuebersichtlichkeit, die gemeldet wurde, nur eine Tuer weiter.

Das AUFKLAPPEN der aelteren bleibt dagegen im Augenblick: kein
Zustand, den man mitschleppt. Nach dem Neuladen ist wieder zu.

GEGEN FREMDE ZEILEN GESCHUETZT: Nur die eigenen, nur die
beantworteten, 404 statt 403 -- wie ueberall im Haus. Ein zweites
„Verstanden" zaehlt nicht noch einmal, sonst wanderte die Zeile bei
jedem Klick ans Ende und man saehe nicht mehr, wann man sie wirklich
gelesen hat.

WAS ICH FALSCH ERWARTET HATTE: Meine erste Pruefung verlangte, dass
nach einem „Verstanden" OBEN NICHTS mehr steht. Sie wurde rot --
Frida hatte zwei Antworten, und der Knopf gilt je Zeile. Die
Pruefung hatte recht; dass er nur seine eigene Zeile nimmt, ist das
gewollte Verhalten und steht jetzt als Aussage dort.

NEBENBEI: Ein 11,2-px-Pfeil (gemeldet von pruef-css-klassen). Unter
11,5 px faengt im Haus die Grenze an, ab der man zusammenkneift --
dass es „nur ein Zeichen" ist, aendert daran nichts.

GEPRUEFT

  pruef-bewerbung-aufgaben  164 -> 178 ok
    Im echten Browser, am Handy (390 px): die ungelesene Antwort
    steht offen mit „Verstanden", danach ist genau diese eine Zeile
    weg (2 -> 1), sie liegt hinter „1 ältere Antwort", zugeklappt
    wird sie gar nicht erst gebaut, aufgeklappt steht sie samt
    Begruendung wieder da, „Alle verstanden" raeumt den Rest, nach
    dem Neuladen gilt beides weiter als gelesen und ist wieder zu.
    Am Server: fremde Antwort 404, erfundene Nummer 404, zweites
    „Verstanden" zaehlt 0.
  pruef-struktur 102 ok, 414 Routen (eine neue) ·
  pruef-css-klassen · pruef-zeichen 7 · pruef-modi-katalog 150 ·
  pruef-vorlagen 24 · pruef-aufgaben-vorlagen 60

NUR DAS AGENTURHAUS. „Eure Aufgaben" liegt unter `/workspace`; am
Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 00:10:15 +02:00
DogFatherGitandClaude Opus 5 018d02c3b8 Vorlagen anpassen: Frist, dauerhaft und eine Anmerkung
VanVan im Support, Meldung #6, zweite Haelfte -- der Rest, der heute
frueh ausdruecklich offen stehen blieb: „Bei den Vorlagen laesst sich
weder die Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch."

EIN ZWEITER KNOPF, NICHT EIN FENSTER FUER ALLE

„An alle" verteilt zwoelf Aufgaben mit einem Druck. Haette jedes
Uebernehmen jetzt ein Fenster geoeffnet, waere der haeufige Weg
langsamer geworden, um den seltenen moeglich zu machen. Neben
„Uebernehmen" steht deshalb „Anpassen …" -- an den Creator-Vorlagen
und am Katalog, aus derselben Funktion.

DAS FENSTER IST DAS DES HAUSES

`frageNach` kann seit heute ein DATUM und einen HAKEN, die Anmerkung
konnte es als `grund` schon. Ein eigener kleiner Dialog in
vorlagenbrett.js waere der zweite im Haus gewesen -- und der, in dem
beim naechsten Mal Esc, Fokusfalle oder der Abbruch fehlen. Beide
Felder sind standardmaessig aus; fuer die dreissig anderen Aufrufe
aendert sich nichts.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. DIE ANMERKUNG WIRD EINE NOTIZ (`aufgaben_notizen`), kein Anhang
    an der Beschreibung. Sie traegt damit, von wem sie stammt, und
    die Aufgabe zeigt sie ohnehin an. In die Beschreibung geschrieben
    waere sie von der Vorlage nicht mehr zu unterscheiden -- und
    dieselbe Vorlage haette beim naechsten Mal einen anderen Text.

 2. EINE DAUERHAFTE AUFGABE BEKOMMT KEINE FRIST, auch wenn eine
    mitgeschickt wird. Sie waere ab dem naechsten Tag fuer immer
    ueberfaellig, und eine Warnung, die immer kommt, ist keine mehr.
    Dieselbe Regel steht seit Langem im Aenderungsweg; haette sie
    hier gefehlt, gaebe es zwei Antworten auf dieselbe Frage.
    Im Fenster wird das Datum deshalb GRAU, sobald der Haken sitzt --
    ein Datum, das dasteht und nicht gilt, ist schlimmer als keins.

 3. „DAUERHAFT" DARF NUR, WER VERTEILEN DARF. Eine dauerhafte Aufgabe
    laesst sich nicht abhaken (Commit von heute frueh); wer sie sich
    selbst anlegen koennte, haette etwas, das er nie wieder loswird.
    Der Haken fehlt deshalb im Fenster eines Creators -- und
    abgelehnt wird trotzdem am Server, nicht nur ausgeblendet.

EIN FUND, DEN DIE PRUEFUNG GEMACHT HAT

`Number(tage) || 7` -- die Untergrenze des Datumsfeldes lag dadurch
sieben Tage in der Zukunft statt heute, weil Null in JavaScript
unwahr ist. Die VORGABE („in 1 Tag") lag damit UNTER der erlaubten
Grenze: Wer das Fenster oeffnete und einfach „Uebernehmen" drueckte,
bekam eine Absage. `Number.isFinite` fragt, ob eine Zahl da ist, und
nicht, ob sie wahr ist -- derselbe Unterschied wie bei `kill -0`.

NEBENBEI BEHOBEN: Ein gerades Anfuehrungszeichen in einem deutschen
Fehlersatz (gemeldet von pruef-struktur).

GEPRUEFT

  pruef-aufgaben-vorlagen   46 -> 60 ok
    eigene Frist kommt an (und die Vorlage saehe 5 Tage vor -- die
    Angabe hat also wirklich gewirkt), Anmerkung wird woertlich zur
    Notiz mit Namen, ohne Angabe bleibt alles wie bisher, Scout 403
    und es entsteht auch nichts, DogFather 200 und KEINE Frist,
    „morgen" 400, der 45.13. 400, Vergangenheit 400, und als
    Gegenprobe dieselbe Vorlage ohne Frist 200.
    Im Browser: der Knopf, das Fenster, die Vorgabe, kein Haken fuer
    einen Creator, die Aufgabe traegt danach genau die eingetragene
    Frist samt Notiz -- und Abbrechen legt nichts an.
  pruef-nachfrage           74 -> 89 ok
    Datum mit Vorgabe und Untergrenze, Haken 44 px hoch mit 22-px-
    Kaestchen (nicht ueber die ganze Zeile), Sperre und Gegenprobe,
    beide Schranken fuer ein zu fruehes Datum einzeln.
  pruef-struktur 102 · pruef-css-klassen · pruef-modi-katalog 150 ·
  pruef-aufgabenbrett · pruef-vorlagen 24 ·
  pruef-bewerbung-aufgaben 164 · pruef-zuteilung

NUR DAS AGENTURHAUS. Vorlagenbrett und Aufgaben liegen unter
`/workspace`; am Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 23:57:41 +02:00
DogFatherGitandClaude Opus 5 b31bd9b515 Zwei bis drei Bilder je Supportmeldung -- und zwei Funde unterwegs
VanVan im Support, Meldung #11, VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufuegen bei einer
Meldung. 2-3 waeren besser." Und in der zweiten Runde der Satz, auf
den es ankommt: „wenn man es nacheinander versucht hinzuzufuegen wird
das Bild immer nur ersetzt."

EINE TABELLE STATT NEUER SPALTEN

`support_bilder` haelt ab jetzt JEDES Supportbild -- das der Meldung
(`runde_nr` NULL) und das einer Antwort (`runde_nr` = Runde). Die
Alternative waere `bild2_datei`, `bild3_datei` gewesen, und beim
vierten Bild wieder. Eine Zeile je Bild kennt keine Obergrenze im
Schema; die Grenze steht an EINER Stelle im Code (`BILDER_MAX = 3`)
und kommt von dort in die Oberflaeche, statt dort ein zweites Mal
zu stehen.

DIE ACHT VORHANDENEN BILDER WANDERN MIT. Ohne diesen Schritt haette
die neue Tabelle ab heute recht und die alten Bilder waeren
unsichtbar -- ohne Fehler, ohne rote Zeile, nur acht leere Karten.
Der Umzug steht NACH der Spaltennachruestung: Er liest
`urteil_bild_datei`, und die gibt es in einer bestehenden Datenbank
erst, nachdem sie ergaenzt wurde. Stuende er davor, scheiterte er
genau dort, wo es darauf ankommt -- live, waehrend lokal alles gruen
bliebe, weil jede Pruefung ihre Datenbank frisch anlegt.

DREI BILDER IN EINER ANFRAGE

`x-bilder: 20481,15320` sagt, wo zu schneiden ist, der Rumpf ist die
Aneinanderreihung. `multipart/form-data` haette einen Zerleger
gebraucht, den dieses Haus nicht hat; drei Anfragen nacheinander
haetten den Zustand „Meldung da, Bild zwei laedt noch" erzeugt --
genau den, gegen den die Kommentare an dieser Route schon vorher
argumentieren. Die Summe muss auf das Byte stimmen, und jedes Stueck
wird einzeln an seinen ersten Bytes erkannt: Wer falsch schneidet,
bekommt eine Absage, kein verfaelschtes Bild. Ohne den Kopf gilt der
ganze Rumpf als ein Bild -- derselbe Satz mit einer Laenge, damit
eine Seite aus dem Zwischenspeicher weiterlaeuft.

EINE ROUTE STATT DREI. `/:id/bild` und `/:id/runde/:nr/bild` sind
weg; es gibt `/:id/bild/:bid`. Wohin ein Bild gehoert, steht in
seiner Zeile -- der Weg muss es nicht wiederholen. Die
Meldungsnummer bleibt trotzdem im Pfad: Sie ist die
Sichtbarkeitsfrage, und beides muss zusammenpassen (gemessen).

ZWEI FUNDE, DIE DIE PRUEFUNG GEMACHT HAT UND NICHT ICH

 1. UEBER DIE SEITE KAM GAR KEIN BILD MEHR AN. Beim Melden stand
    kein `Content-Type`. Das ging gut, solange der Rumpf eine
    einzelne Datei war -- ein `File` bringt seinen Typ mit. Ein
    `Blob` aus mehreren hat keinen, `fetch` schickt die Zeile dann
    gar nicht, `express.raw` fuehlt sich nicht zustaendig, und der
    Server bekam einen leeren Rumpf. Die Meldung waere durchgegangen,
    der Text angekommen, die Bilder weg -- ohne Fehlermeldung. Alle
    Pruefungen am Server waren dabei gruen; gefunden hat es erst der
    echte Browser.

 2. DAS KREUZ DES DRITTEN BILDES LAG AUF DEM ZWEITEN. Der
    Entfernen-Knopf ist 44 px breit und absolut gesetzt, der Kasten
    aber nur so breit wie sein Bild. Bei einem schmalen Bild ragt er
    darueber hinaus -- wer „das zweite weg" antippt, loescht das
    dritte. `min-width`/`min-height` loesen das an der Ursache: Ein
    Kasten ist nie schmaler als der Knopf in ihm.

WAS ICH FALSCH ANGENOMMEN HATTE: Ich hatte eingebaut, dass ein
Nachtrag in derselben Runde die Bilder ersetzt. Die Pruefung dazu
wurde rot -- zu Recht: Eine zweite Antwort in derselben Runde kann
es nicht geben, die erste verlaesst den Stand „wartet". Der Code
waere nie gelaufen und damit nie pruefbar gewesen. Er ist weg; an
seiner Stelle steht der Beweis, dass er nicht fehlt.

DREI WEITERE ROTE ZEILEN, DIE NICHT ZU DIESEM UMBAU GEHOERTEN

 * `manager-ziele.js` hatte einen ZWEITEN Notnagel
   (`frageNach ? … : confirm(…)`). `nachfrage.js` hat denselben
   laengst, und zwar mit dem vollstaendigen Text; der hiesige war
   der kuerzere und haette gewonnen. Zwei Antworten auf dieselbe
   Frage -- gemeldet von `pruef-nachfrage`.
 * Zwei Mittelpunkte in `reaktion.css` standen woertlich im
   `content`. Sie liegen im Latin-1-Block, wo `pruef-zeichen` die
   Truemmer einer verunglueckten Kodierung sucht. Jetzt als Escape
   -- im Browser nachgemessen, es steht Zeichen fuer Zeichen
   dasselbe da.
 * Das Aufraeumen nach 90 Tagen loeschte nur das EINE Bild der
   Meldung; die Bilder aus den Antwortrunden blieben ohne Zeile auf
   der Platte liegen. Die Liste kommt jetzt aus einer Abfrage statt
   aus einer Spalte und kann deshalb nicht wieder unvollstaendig
   sein.

GEPRUEFT

  pruef-support            78 -> 104 ok
    darunter: der Umzug der alten Bilder auf einer eigenen
    Wegwerf-Datenbank -- zweimal und dreimal gestartet, nichts
    verdoppelt, Datum von damals erhalten
  pruef-support-bilder     NEU, 36 ok (echter Browser)
    dreimal nacheinander waehlen ergibt drei, das vierte wird mit
    einem Satz abgelehnt, dasselbe zaehlt nicht doppelt, einzeln
    entfernen laesst die anderen stehen, alle drei laden wirklich
    (naturalWidth), Kreuze 44x44 und keines verdeckt (mit Gegenprobe
    per Deckel), nichts ragt auf 390 px heraus
  pruef-nachfrage          69 -> 74 ok
  pruef-struktur          102 ok, 413 Routen (vorher 414: zwei weg,
                          eine neu)
  pruef-zeichen             7 ok (vorher 1 Fehler)
  pruef-aufbewahrung       45 ok
  pruef-manager-ziele     216 ok
  pruef-ports              10 ok · pruef-portnummern 41 ok
    (die neue Pruefdatei verschiebt die abgeleiteten Nummern)

NUR DAS AGENTURHAUS IST BETROFFEN. Die Supportseite liegt unter
`/workspace`; am Crew-Haus aendert sich keine Zeile.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 23:38:03 +02:00
DogFatherGitandClaude Opus 5 3ab69d36a5 Eine dauerhafte Aufgabe wird nicht abgehakt -- auch nicht ueber den Status
VanVan im Support, Meldung #6: „Der Modi kann die dauerhafte Aufgabe
immer noch auf erledigt setzen."

DIE SPERRE GAB ES SEIT DEM 30.09. -- ABER NUR AN EINER TUER.

`/mein-stand` lehnt „erledigt" bei einer dauerhaften Aufgabe seither
ab. Der STATUSWEG (`PATCH /workspace/api/aufgaben/:id`) kannte
`dauerhaft` ueberhaupt nicht. Wer die Aufgabe ohnehin aendern durfte --
etwa weil das Uebernehmen aus dem Pool ihn verantwortlich macht --
hakte sie damit einfach ab. Zwei Tueren, eine Regel, und die Regel
hing nur an einer.

UND ICH HABE DAS LOCH HEUTE FRUEH VERBREITERT. Mit dem Commit davor
darf eine Modi den Status ihrer Aufgabe setzen (damit VanVans Satz
„es gibt darüber ja den button starten" fuer sie ueberhaupt stimmt).
Damit stand ihr genau der Weg offen, der ihr an der anderen Tuer
ausdruecklich verwehrt ist. Gefunden habe ich es nicht beim Bauen,
sondern beim Lesen der offenen Supportmeldungen -- ihr Satz stand seit
dem 28.09. da und passte ploetzlich auf meine eigene Aenderung.

GEAENDERT

 1. Der Statusweg lehnt „erledigt" bei einer dauerhaften Aufgabe ab,
    wenn die Person nicht verteilen darf. DERSELBE Fehlercode wie in
    `/mein-stand` (`dauerhafte_aufgabe`) -- die Oberflaeche uebersetzt
    ihn schon, und ein zweiter Code fuer dieselbe Sache waere der, den
    beim naechsten Mal jemand uebersetzt und der andere nicht.

 2. Der Knopf faellt weg, der die Absage holen wuerde (`darf_beenden`
    vom Server). Dieselbe Ueberlegung wie bei „Fertig" auf der
    Zuteilungskarte, die seit dem 30.09. daneben steht: Ein Knopf, der
    eine Absage holt, ist schlimmer als keiner.

    NUR DER LETZTE SCHRITT faellt weg. „starten" und „zur Freigabe"
    bleiben -- auch eine stehende Aufgabe hat einen Anfang, und der
    Unterschied zwischen „offen" und „in Arbeit" sagt etwas.

 3. BEENDET WIRD SIE VON DER LEITUNG. Das stand seit dem 30.09. als
    Satz im Kommentar von `/mein-stand`; jetzt stimmt er auch.

GEPRUEFT -- UND ZWAR BEIDE TUEREN, sonst wandert der Fehler nur:

    eine dauerhafte Aufgabe (#3)
    Tuer 1 (mein-stand) ist zu (409 dauerhafte_aufgabe)
    Tuer 2 (Status) jetzt auch (409 dauerhafte_aufgabe)
      anfangen darf sie trotzdem (200)
      und die Leitung beendet sie (200)
    und die Oberflaeche erfaehrt es (darf_beenden false)

Die dritte Zeile ist die Gegenprobe gegen zu viel Sperre: „gesperrt"
darf nicht heissen, dass gar nichts mehr geht.

GEPRUEFT: pruef-zuteilung 90 -> 96 ok · pruef-bewerbung-aufgaben 164 ·
pruef-aufgabenbrett 49 · pruef-aufgaben-vorlagen 46 · pruef-struktur 102.

WAS AUS DERSELBEN MELDUNG NOCH OFFEN IST (VanVan, #6): Bei den
VORLAGEN laesst sich weder die Frist anpassen noch „dauerhaft"
einstellen, und eine Anmerkung fehlt auch. Das ist ein eigener Umbau
und steht hier nur, damit es nicht untergeht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:26:55 +02:00
DogFatherGitandClaude Opus 5 2fb92f86f3 Die Routenwache war unvollstaendig -- 13 Routen waren ihr unsichtbar
GEFUNDEN ALS FEHLALARM, GEBLIEBEN IST EIN ECHTER FUND.

pruef-struktur meldete:

    assets/js/manager-ziele.js: Schnittstelle gibt es nicht
      -> /workspace/api/manager-ziele

Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE auf. In
Zeile 31 steht `const BASIS = '/workspace/api/manager-ziele'`, benutzt
wird ausschliesslich `${BASIS}/stand`, `${BASIS}/eintraege`,
`${BASIS}/eintrag/${id}`.

DER EIGENTLICHE FUND LAG EINE EBENE TIEFER. Der SERVER registriert
seine Routen genauso:

    const BASIS = "/workspace/api/manager-ziele";
    managerZieleRouter.get(`${BASIS}/stand`, ...)

Die Sammelregel verlangte aber, dass ein Routenpfad direkt mit
`/workspace/` beginnt. ALLE DREIZEHN Routen dieses Moduls fehlten
damit in der Liste -- gemessen, nicht geschaetzt. Die Wache war also
nicht zu streng, sie war UNVOLLSTAENDIG: Zu diesen Routen konnte sie
gar nichts sagen, weder dass es sie gibt noch dass es sie nicht gibt.
Und weil die Aufrufe dorthin ebenfalls zusammengesetzt sind, ist es
nie aufgefallen -- ausser an dieser einen nackten Konstanten.

    Server-Routen eingelesen:  401 -> 414

GEAENDERT

 1. Einfache Praefix-Konstanten werden je Datei aufgeloest. Mehr
    nicht: Wer seinen Pfad aus drei Variablen zusammensetzt, bleibt
    unauffindbar -- und das ist richtig so, denn geraten wird hier
    nicht. Ohne bekannten Wert wird GAR NICHTS eingetragen; eine
    geratene Route waere schlimmer als eine fehlende, weil sie einen
    toten Aufruf als lebendig durchgehen liesse.

 2. Eine nackte Praefix-Konstante im Browser gilt als Praefix, wenn
    es Routen DARUNTER gibt. Abgeleitet, nicht aufgezaehlt -- eine
    Ausnahmeliste mit „manager-ziele" darin waere die naechste, die
    niemand pflegt.

GEGENPROBEN IN BEIDE RICHTUNGEN, weil die neue Regel etwas
durchlaesst:

    eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)
    eine erfundene Adresse OHNE Routen darunter bleibt ein Fund
    und eine echte Route bleibt eine echte Route, kein Praefix

DAZU VIER SCHLUSSZEICHEN. Die Wache fuer das deutsche
Anfuehrungszeichen meldete vier Stellen in denselben zwei Dateien:
`„${was}" löschen?` und drei Geschwister. Berichtigt auf `“`.

UND EINE KORREKTUR AN MIR: Ich habe pruef-struktur heute dreimal als
„99 ok, Exitcode 0" protokolliert und dabei zwei rote Zeilen
uebersehen -- mein Filter zeigte nur die ersten drei FEHL-Zeilen und
die Zahl der gruenen. Gefunden habe ich es erst, als dieselbe Pruefung
spaeter Exitcode 1 meldete. Nachgemessen mit `git stash`: Die zwei
Funde stecken auch in HEAD, sie sind also nicht aus meiner laufenden
Arbeit gekommen. Eine Zusammenfassung, die nur die gruenen Zeilen
zaehlt, ist keine.

GEPRUEFT: pruef-struktur 102 ok, 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:25:54 +02:00
DogFatherGitandClaude Opus 5 63e3924a08 Die Erinnerungen wirklich zugestellt -- und eine Zeile, an der alles hing
`pruef-manager-ziele` prueft, was `zielRufe()` ZURUECKGIBT. Eine Liste
ist aber keine Benachrichtigung. Dazwischen liegen noch: der
Fuenf-Minuten-Takt, die Artenliste, der Schalter in der Glocke, die
Ruhezeit, die Verschluesselung und das Merkmal gegen Doppelsendungen.
Dieser Weg war genauso nie gelaufen wie der Monatswechsel -- und er
laeuft zum ersten Mal am 1. November um 00:05 Uhr, von selbst, fuer
alle gleichzeitig.

DIE WICHTIGSTE FRAGE STAND GLEICH AM ANFANG

Am 1. um 00:05 ist RUHEZEIT (Vorgabe 22 bis 7). Die Meldung darf dann
nicht herausgehen -- aber sie darf auch nicht VERFALLEN. Das haengt an
einer einzigen Zeile in `benachrichtige`: Das Merkmal wird erst
geschrieben, NACHDEM wirklich zugestellt wurde. Waere es umgekehrt,
bekaeme am 1. November niemand eine Meldung, und zwar fuer immer --
der Takt haette sie als „schon geschickt" abgehakt, waehrend alle
schliefen. Gemessen: nachts null zugestellt UND null Merkmale, um
09:00 dann zwei. Die Zeile haelt.

GEPRUEFT WIRD DIE ECHTE UHRZEIT, NICHT EINE GEFAELSCHTE RUHEZEIT. Das
Haus kann die Ruhezeit per Umgebungsvariable verstellen; das waere
hier die falsche Frage gewesen. Gefragt ist, was am 1. November um
fuenf nach null passiert -- also wird die Uhr dorthin gestellt.

WAS SONST NOCH BEWIESEN IST (20 Pruefungen, 0 Fehler)

  * Der Takt laeuft 288-mal am Tag. Drei weitere Laeufe direkt
    hintereinander stellen NICHTS mehr zu -- ohne das Merkmal
    bekaeme jeder 288 Meldungen und legte das Handy weg.
  * Der Schalter in der Glocke schlaegt die Erinnerung: Wer die Art
    abgeschaltet hat, bekommt nichts, obwohl bei ihm etwas offen ist.
  * Ein Creator bekommt nichts (er hat diese Pflichten nicht), und
    wer kein Geraet angemeldet hat, erzeugt keine Geisterzustellung.
  * Am 11. ist Ruhe. Ohne diese Gegenprobe hiesse „es kommt an"
    moeglicherweise „es kommt jeden Tag", und der ganze Terminplan
    der Vorlage waere wirkungslos.
  * Der Glueckwunsch kommt genau einmal, und danach ist fuer den
    Fertigen Ruhe -- waehrend der andere am 16. weiter gemahnt wird.

GEMESSEN WERDEN ZUSTAENDE, NICHT ZEITPUNKTE. Der Server startet seinen
eigenen Takt und kann jederzeit in die Pruefung hineinlaufen. Deshalb
steht nirgends „nach meinem Aufruf kamen genau drei dazu", sondern „in
push_verschickt steht jetzt genau eine Zeile je Person".

UND DIE ERSTE PRUEFUNG DER DATEI PRUEFT DIE PRUEFUNG. „Null
zugestellt" ist gleich die erste erwartete Antwort; ohne den Beweis,
dass ueberhaupt etwas ankommen KANN, hiesse sie moeglicherweise „der
Dienst ist gar nicht angeschlossen".

NUR DIESE EINE DATEI IST DRIN. Der erste Anlauf dieses Commits hat
mit `git add -A` drei Dateien mitgenommen, die ich nie angefasst
habe -- sie wurden waehrenddessen von einer anderen Sitzung im selben
Verzeichnis bearbeitet (Zeitstempel: Sekunden alt). Zurueckgenommen,
bevor etwas gepusht wurde; ihre Arbeit liegt unveraendert im
Arbeitsverzeichnis.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:18:56 +02:00
120 changed files with 13692 additions and 1495 deletions
+319
View File
@@ -0,0 +1,319 @@
/* =====================================================================
helfer-ffmpeg.mjs — eine Sprachnachricht, die JEDES Gerät abspielt
=====================================================================
WARUM ES DIESE DATEI GIBT (03.10.2026)
Miss im Support, Meldung #12 — fünf Runden lang: „Bei mir lässt
sich die Nachricht nicht abspielen." Zuletzt: „Jetzt lädt es die
ganze Zeit." Bei allen anderen ging es.
GEMESSEN, BEVOR GEBAUT WURDE:
· Alle sechs Sprachnachrichten im Haus sind `audio/webm`.
· Miss' Gerät: iPhone, iOS 18.7, WebKit.
· Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
wurde KEINE EINZIGE neue aufgenommen — ihr Problem betrifft
also ausschließlich die sechs alten Dateien.
WAS ICH NICHT MESSEN KONNTE, und das gehört hierher: Ob WebKit
WebM/Opus abspielen kann, lässt sich auf diesem Rechner nicht
nachsehen — Playwrights WebKit startet hier nicht (`libegl.dll`
fehlt). Die Vermutung „iPhones können kein WebM" ist deshalb
begründet, aber nicht von mir gemessen.
GENAU DESHALB IST DIE LÖSUNG HIER EINE, DIE DIE FRAGE NICHT
STELLT. Statt zu raten, welches Gerät welchen Behälter kann, legt
der Server neben jede Sprachnachricht eine zweite Fassung in dem
Format, das seit zwanzig Jahren überall läuft: AAC in MP4. Wer sie
hat, bekommt sie. Kein Gerätename im Code, keine Liste, die
altert.
---------------------------------------------------------------------
WARUM EIN FREMDES PROGRAMM UND NICHT NODE
Node kann kein Audio umwandeln, und ein Paket dafür wäre die
einzige nennenswerte Abhängigkeit in einem Haus, das ohne Baulauf
auskommt. ffmpeg ist auf dem Server installiert (7.1.5, Debian) —
abgesprochen, nicht nebenbei eingerichtet.
DREI AUSGÄNGE, wie überall: umgewandelt / nicht umwandelbar /
ffmpeg nicht auffindbar. Der dritte ist KEIN Fehler: Fehlt ffmpeg,
wird die Sprachnachricht wie bisher im Originalformat
ausgeliefert. Niemand verliert etwas, was er vorher hatte — es
kommt nur niemand dazu.
===================================================================== */
import { existsSync, renameSync, unlinkSync } from "node:fs";
import { execFile, execFileSync } from "node:child_process";
import { delimiter, join, extname, basename, dirname } from "node:path";
/* Die Endung der Zweitfassung. AAC in MP4 — das Format, bei dem sich
iPhone, Android, Windows, Mac und jeder Browser einig sind. */
export const ZWEITFASSUNG_ENDUNG = ".m4a";
export const ZWEITFASSUNG_TYP = "audio/mp4";
/* 96 kbit/s für Sprache. Opus holt bei 32 kbit/s dasselbe heraus,
AAC braucht mehr — und eine Sprachnachricht von einer Minute wiegt
damit 700 KB statt 240. Das ist der Preis dafür, dass sie überall
läuft, und er ist es wert. Niedriger wäre hörbar dumpf; höher
brächte bei einem Handymikrofon nichts. */
const RATE = "96k";
/* Nach dieser Zeit wird abgebrochen. Eine Sprachnachricht von einer
Minute braucht auf dem Server unter einer Sekunde; wer hier in die
Frist läuft, hat ein anderes Problem, und ein hängender Prozess
wäre das schlimmere. */
const FRIST_MS = 60_000;
const BEKANNTE_ORTE = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"C:/Program Files/ffmpeg/bin/ffmpeg.exe",
];
let gemerkt = null;
let schonGesagt = false;
/* IM PATH SUCHEN, OHNE `where` ODER `which` ZU BRAUCHEN — dieselbe
Überlegung wie in helfer-openssl.mjs: Beide sind selbst Programme
und können genauso fehlen. */
function imPfad() {
const pfade = (process.env.PATH || "").split(delimiter).filter(Boolean);
const namen = process.platform === "win32" ? ["ffmpeg.exe", "ffmpeg"] : ["ffmpeg"];
for (const p of pfade) {
for (const n of namen) {
const voll = join(p, n);
if (existsSync(voll)) return voll;
}
}
return null;
}
/** Wo liegt ffmpeg — oder `null`.
*
* GIBT `null` ZURUECK UND WIRFT NICHT. Anders als bei openssl ist
* das Fehlen hier kein Abbruchgrund: Die Sprachnachricht
* funktioniert auch ohne Zweitfassung, nur eben nicht überall. Ein
* geworfener Fehler würde das Hochladen scheitern lassen — und
* damit aus einer fehlenden Verbesserung einen Ausfall machen. */
export function ffmpegPfad() {
if (gemerkt !== null) return gemerkt || null;
gemerkt = imPfad() || BEKANNTE_ORTE.find((o) => existsSync(o)) || "";
if (!gemerkt && !schonGesagt) {
schonGesagt = true;
/* EINMAL SAGEN, NICHT BEI JEDER NACHRICHT. Eine Zeile, die bei
jedem Hochladen kommt, liest nach dem dritten Mal niemand
mehr — und deckt dabei die echten zu. */
console.log("[ton] ffmpeg nicht gefunden – Sprachnachrichten bekommen keine "
+ "zweite Fassung. Auf Debian: sudo apt-get install -y ffmpeg");
}
return gemerkt || null;
}
/** Wo liegt ffprobe — oder `null`.
*
* ES LIEGT IMMER NEBEN ffmpeg (dasselbe Paket, derselbe Ordner),
* deshalb wird es dort gesucht und nicht noch einmal im PATH.
* Gebraucht wird es nur von den Prüfungen: Nur ffprobe kann sagen,
* ob in der erzeugten Datei wirklich AAC steckt und ob sie so lang
* ist wie das Original. Ohne diese Frage wäre „die Datei ist da"
* ein grüner Haken über einer Datei, die niemand abspielen kann. */
export function ffprobePfad() {
const ff = ffmpegPfad();
if (!ff) return null;
const nachbar = ff.replace(/ffmpeg(\.exe)?$/i, (m) => m.replace(/ffmpeg/i, "ffprobe"));
return existsSync(nachbar) ? nachbar : null;
}
/** Der Pfad, unter dem die Zweitfassung einer Datei liegt (oder läge). */
export function zweitfassungPfad(pfad) {
return join(dirname(pfad), basename(pfad, extname(pfad)) + ZWEITFASSUNG_ENDUNG);
}
/** Braucht diese Datei überhaupt eine Zweitfassung?
*
* NEIN, WENN SIE SCHON DAS ZIELFORMAT IST. Eine m4a nach m4a zu
* wandeln kostet Rechenzeit und Qualität für genau nichts — und
* seit dem 02.10. nehmen die meisten Geräte ohnehin MP4 auf. */
export function brauchtZweitfassung(pfad) {
return extname(pfad).toLowerCase() !== ZWEITFASSUNG_ENDUNG;
}
/** Taugt eine vorhandene Zweitfassung noch?
*
* ==== WARUM DIESE FRAGE UEBERHAUPT GESTELLT WIRD (03.10.2026) ====
*
* Die ersten sechs Zweitfassungen sind entstanden, BEVOR das Loch
* am Anfang bekannt war (siehe `asetpts` weiter unten). Vier davon
* tragen es: 387 s, 164 s, 134 s und 63,8 s Zeitachse fuer jeweils
* wenige Sekunden Ton. Sie liegen auf dem Server und gehoeren dem
* Dienstbenutzer — von aussen sind sie nicht wegzuraeumen.
*
* EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG
* und die falsche: Eine Zahl, die jemand hochzaehlen muss, wird
* beim naechsten Mal vergessen. Gefragt wird deshalb die DATEI
* SELBST — und zwar nach dem, was schiefgehen kann: Passt ihre
* Zeitachse zu der Tonmenge, die sie traegt?
*
* GERECHNET WIRD AUS DEN TONRAHMEN. AAC packt 1024 Abtastwerte in
* einen Rahmen; Rahmenzahl mal 1024 geteilt durch die Abtastrate
* ist die echte Laenge. Weicht die angegebene Dauer um mehr als
* anderthalb Sekunden nach OBEN ab, steckt ein Loch darin.
*
* DRITTER AUSGANG: Sagt ffprobe nichts (fehlt, oder die Datei gibt
* die Rahmenzahl nicht her), gilt die Fassung als tauglich. Eine
* Datei wegzuwerfen, weil man sie nicht beurteilen kann, waere die
* schlechtere Antwort — und ein Lauf, der bei jedem Durchgang alles
* neu wandelt, waere eine Warnung, die immer kommt. */
export function zweitfassungTaugt(ziel) {
const probe = ffprobePfad();
if (!probe) return true;
try {
/* ==== MIT NAMEN LESEN, NICHT NACH REIHENFOLGE =================
Hier stand `-of default=…:nokey=1` und danach
`const [rahmen, rate, dauer] = …`. ffprobe gibt die Werte aber
in SEINER Reihenfolge aus, nicht in meiner: erst
`sample_rate`, dann `nb_frames`. Die Rechnung lief damit mit
48000 Rahmen und 95 Hz — und kam auf eine „echte Laenge" von
einer halben Million Sekunden. Die Funktion haette JEDE Datei
fuer tauglich erklaert, immer, und nie etwas gemeldet.
Das ist genau die Sorte, vor der die Hausregel warnt: Sie war
gruen und wertlos. Aufgefallen ist es nicht beim Lesen,
sondern beim Bauen der Gegenprobe — die Frage „kann sie
ueberhaupt NEIN sagen?" hat sie gestellt. */
const roh = execFileSync(probe, ["-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=nb_frames,sample_rate", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1", ziel], { encoding: "utf8", timeout: 20_000 });
const wert = (name) => {
const t = roh.match(new RegExp(`^${name}=(.+)$`, "m"));
return t ? Number(t[1]) : NaN;
};
const rahmen = wert("nb_frames");
const rate = wert("sample_rate");
const dauer = wert("duration");
/* DRITTER AUSGANG: Fehlt eine der drei Zahlen, ist die Frage
nicht zu beantworten -- und „nicht beurteilbar" heisst hier
„in Ordnung lassen". Eine Datei wegzuwerfen, weil man sie
nicht messen kann, waere die schlechtere Antwort. */
if (!Number.isFinite(rahmen) || !rahmen) return true;
if (!Number.isFinite(rate) || !rate) return true;
if (!Number.isFinite(dauer)) return true;
/* AAC packt 1024 Abtastwerte in einen Rahmen. */
const echt = (rahmen * 1024) / rate;
return dauer <= echt + 1.5;
} catch { return true; }
}
/** Eine Tondatei in AAC/MP4 danebenlegen.
*
* Gibt ein Versprechen auf `true` (liegt jetzt da), `false` (ging
* nicht) oder `null` (ffmpeg fehlt — konnte nicht nachsehen).
*
* SIE WIRFT NIE. Der Aufrufer hängt an einem Hochladen, und ein
* Fehler beim Umwandeln darf die Nachricht nicht verhindern.
*
* `-movflags +faststart` IST NICHT KOSMETIK: Ohne es steht die
* Inhaltsübersicht einer MP4 am ENDE der Datei. Ein Abspielgerät
* muss dann erst bis ans Ende lesen, bevor es anfangen kann — bei
* einer Teilanfrage über eine mobile Verbindung ist das genau das
* „lädt die ganze Zeit", über das sich Miss beklagt hat. */
export function zweitfassungBauen(pfad) {
return new Promise((fertig) => {
const ff = ffmpegPfad();
if (!ff) return fertig(null);
const ziel = zweitfassungPfad(pfad);
/* SCHON DA HEISST FERTIG. Der Nachrüstlauf geht mehrmals über
denselben Ordner; ohne diese Zeile würde er jedes Mal alles
neu wandeln. */
if (existsSync(ziel)) return fertig(true);
/* ==== ERST FERTIG, DANN SICHTBAR (03.10.2026) ================
GEFUNDEN VON DER PRÜFUNG, nicht beim Nachdenken: Sie holte die
zweite Fassung ab und bekam 44 Bytes.
ffmpeg LEGT DIE ZIELDATEI SOFORT AN und füllt sie danach — bei
`+faststart` schreibt es sie am Ende sogar noch einmal um. Die
Stelle, die fragt „gibt es die zweite Fassung?", sieht in
dieser Zeit eine Datei, die es zwar gibt, die aber noch nichts
enthält. Wer dann zuhört, bekommt ein Bruchstück.
Deshalb entsteht sie unter einem ANDEREN Namen und wird erst
am Ende umbenannt. Umbenennen im selben Ordner ist EIN Schritt:
Entweder die Datei ist vollständig da oder gar nicht.
DER BRUCHSTÜCKNAME ENDET NICHT AUF `.m4a` — sonst hielte
`zweitfassungDa()` ihn für das Ergebnis, und die Lücke wäre
nur umbenannt. Dafür muss das Format ausdrücklich dastehen
(`-f mp4`): ffmpeg wählt es sonst an der Endung, und `.teil`
kennt es nicht. (Auch das hat die Prüfung gefunden — der erste
Versuch mit Umbenennen erzeugte gar keine Datei mehr.) */
const bruchstueck = ziel + ".teil";
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
execFile(ff, [
"-nostdin", "-hide_banner", "-loglevel", "error",
"-i", pfad,
/* KEIN BILD. Eine WebM kann ein Titelbild enthalten; in einer
Audiodatei hätte es nichts verloren und bringt ffmpeg bei
manchen Dateien zum Stolpern. */
"-vn",
/* ==== DAS LOCH AM ANFANG (03.10.2026, gemessen) =============
An einer echten Sprachnachricht aus dem Haus nachgesehen
(`mumqbw6v…webm`, 29.09.): Sie enthält 46 Pakete mit
zusammen 2,76 Sekunden Ton — verteilt über eine Zeitachse
von 63,8 Sekunden. Das erste Paket steht bei 0,0 s, das
zweite bei 61,1 s. Dazwischen: nichts.
WAS DAS FÜR DEN HÖRER HEISST: Das Abspielgerät zeigt 1:03,
springt beim Antippen in eine Minute Stille und fängt erst
danach an. Auf einem Handy über Mobilfunk sieht genau das
aus wie „lädt die ganze Zeit" — Miss' Satz aus Runde 5.
DAS IST NICHT UNSER FEHLER UND TROTZDEM UNSER PROBLEM:
`MediaRecorder` setzt diesen Versatz selbst. Reparieren
lässt er sich nur hier, beim Umwandeln.
`asetpts=N/SR/TB` vergibt die Zeitstempel neu, fortlaufend
ab null. Nachgemessen bleibt der Toninhalt dabei
unverändert: 260 KiB dekodiert vorher wie nachher, nur die
Länge geht von 63,8 s auf 2,76 s. Es wird also nichts
abgeschnitten, sondern ein Loch geschlossen.
WAS ES KOSTET: Eine Aufnahme mit einer echten Pause in der
Mitte rückt zusammen. Bei einer Sprachnachricht ist das
richtig so — `MediaRecorder` nimmt während einer Pause
ohnehin nichts auf, es entstünde also auch dort nur ein
Loch aus Stille. */
"-af", "asetpts=N/SR/TB",
"-c:a", "aac", "-b:a", RATE,
"-movflags", "+faststart",
/* NICHT ÜBERSCHREIBEN. Eine halbe Datei von einem
abgebrochenen Lauf soll auffallen, nicht stillschweigend
ersetzt werden -- und `existsSync` oben hat ohnehin schon
gefragt. */
"-f", "mp4", "-n", bruchstueck,
], { timeout: FRIST_MS }, (fehler) => {
const weg = () => {
try { if (existsSync(bruchstueck)) unlinkSync(bruchstueck); } catch { /* egal */ }
};
if (fehler) {
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} ging nicht:`,
String(fehler.message || fehler).split("\n")[0]);
weg();
return fertig(false);
}
/* DER EINE SCHRITT, DER SIE SICHTBAR MACHT. Siehe oben: Erst
fertig, dann unter dem Namen, nach dem gefragt wird. */
try { renameSync(bruchstueck, ziel); } catch (f) {
console.error(`[ton] Zweitfassung fuer ${basename(pfad)} nicht umbenannt:`,
f?.message);
weg();
return fertig(false);
}
fertig(true);
});
});
}
+61
View File
@@ -0,0 +1,61 @@
/* =====================================================================
EIN PNG SCHREIBEN (03.10.2026)
Das Gegenstueck zu helfer-png.mjs, das eines LIEST. Gebraucht fuer
die Eventkarten-Pruefung: Ob der Titel auf einer Buehne lesbar
bleibt, haengt davon ab, was fuer ein Banner darunter liegt -- und
der interessante Fall ist nicht das dunkle, sondern das HELLE, das
jemand spaeter hochlaedt. Ein solches Bild muss die Pruefung also
selbst herstellen koennen.
WARUM SELBST GESCHRIEBEN: Dieselbe Begruendung wie bei helfer-png.mjs
-- das Projekt hat kein Bildmodul, und fuer ein Testbild eines
nachzuinstallieren waere eine Abhaengigkeit, die jemand pflegen
muss. Node bringt zlib mit; ein unkomprimiertes RGB-PNG ist danach
nur noch Pruefsumme und Zeilenfilter.
===================================================================== */
import { deflateSync } from "node:zlib";
const tabelle = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = tabelle[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function teil(name, inhalt) {
const kopf = Buffer.alloc(8);
kopf.writeUInt32BE(inhalt.length, 0);
kopf.write(name, 4, "ascii");
const pruef = Buffer.alloc(4);
pruef.writeUInt32BE(crc32(Buffer.concat([Buffer.from(name, "ascii"), inhalt])), 0);
return Buffer.concat([kopf, inhalt, pruef]);
}
/** male(x, y, b, h) -> [r,g,b] */
export function pngBauen(b, h, male) {
const roh = Buffer.alloc((b * 3 + 1) * h);
let p = 0;
for (let y = 0; y < h; y++) {
roh[p++] = 0;
for (let x = 0; x < b; x++) {
const f = male(x, y, b, h);
roh[p++] = f[0]; roh[p++] = f[1]; roh[p++] = f[2];
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(b, 0); ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
teil("IHDR", ihdr),
teil("IDAT", deflateSync(roh, { level: 9 })),
teil("IEND", Buffer.alloc(0)),
]);
}
+5 -1
View File
@@ -66,7 +66,7 @@ import { ampelRouter } from "./workspace-ampel.js";
import { checklisteRouter } from "./workspace-checkliste.js"; import { checklisteRouter } from "./workspace-checkliste.js";
import { teamlageRouter } from "./workspace-teamlage.js"; import { teamlageRouter } from "./workspace-teamlage.js";
import { pushRouter, pushStarten } from "./workspace-push.js"; import { pushRouter, pushStarten } from "./workspace-push.js";
import { chatRouter } from "./workspace-chat.js"; import { chatRouter, toeneNachruestenStarten } from "./workspace-chat.js";
import { anrufRouter } from "./workspace-anruf.js"; import { anrufRouter } from "./workspace-anruf.js";
import { videoRouter, videoWacheStarten } from "./workspace-video.js"; import { videoRouter, videoWacheStarten } from "./workspace-video.js";
import { bewerbungRouter } from "./workspace-bewerbung.js"; import { bewerbungRouter } from "./workspace-bewerbung.js";
@@ -370,6 +370,10 @@ app.use(contentRouter);
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */ Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
sicherungStarten(); sicherungStarten();
aufbewahrungStarten(); aufbewahrungStarten();
/* Sprachnachrichten in eine Fassung bringen, die jedes Geraet
abspielt (Miss im Support, Meldung #12). Der Lauf meldet sich
nur, wenn er etwas getan hat. */
toeneNachruestenStarten();
/* Sieht taeglich nach, ob die verlinkten Videos bei TikTok noch da /* Sieht taeglich nach, ob die verlinkten Videos bei TikTok noch da
sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein
Knopf -- man klickt ihn ja. */ Knopf -- man klickt ihn ja. */
+17 -8
View File
@@ -77,15 +77,21 @@ const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAGQAAADwCAYAAAB7ovsMAAAAWklEQVR42u3QMQEAAAjAoNu/" "iVBORw0KGgoAAAANSUhEUgAAAGQAAADwCAYAAAB7ovsMAAAAWklEQVR42u3QMQEAAAjAoNu/"
+ "tBmeIAfpVoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAA" + "tBmeIAfpVoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAA"
+ "AQIECBAgQIAAAQIECBAgQIAAAe4W1H8B4aXvOgAAAABJRU5ErkJggg==", "base64"); + "AQIECBAgQIAAAQIECBAgQIAAAe4W1H8B4aXvOgAAAABJRU5ErkJggg==", "base64");
d.prepare(`INSERT INTO support_meldungen /* DIE BILDER STEHEN IN `support_bilder` (ab 02.10.2026), nicht mehr
(person_id, text, seite, bild_datei, bild_typ, bild_breite, bild_hoehe, in Spalten der Meldung. Hier direkt dort eintragen und NICHT auf
stand, erstellt, geaendert) die Uebernahme beim Start hoffen: Die ist schon gelaufen, der
VALUES (?,?,?,?,?,?,?,?,?,?)`) Server steht oben in dieser Datei laengst. */
.run(idModi, const meldungId = d.prepare(`INSERT INTO support_meldungen
(person_id, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?) RETURNING id`)
.get(idModi,
"Ich konnte da wieder nicht zurück gehen, musste die App schließen " "Ich konnte da wieder nicht zurück gehen, musste die App schließen "
+ "damit ich wieder auf die Hauptseite kam", + "damit ich wieder auf die Hauptseite kam",
"/workspace/support.html", "probe.png", "image/png", 100, 240, "/workspace/support.html", "in_arbeit", jetzt, jetzt).id;
"in_arbeit", jetzt, jetzt); d.prepare(`INSERT INTO support_bilder
(meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
VALUES (?,NULL,?,?,?,?,?)`)
.run(meldungId, "probe.png", "image/png", 100, 240, jetzt);
d.prepare(`INSERT INTO support_meldungen d.prepare(`INSERT INTO support_meldungen
(person_id, text, seite, stand, erstellt, geaendert) (person_id, text, seite, stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?)`)
@@ -95,7 +101,10 @@ d.prepare(`INSERT INTO support_meldungen
const { DATEN_ORDNER } = await import("./workspace.js"); const { DATEN_ORDNER } = await import("./workspace.js");
try { try {
const { mkdirSync, writeFileSync } = await import("node:fs"); const { mkdirSync, writeFileSync } = await import("node:fs");
const ziel = join(DATEN_ORDNER, "support"); /* DER ORDNER HEISST `support-bilder`, nicht `support` -- so steht
es in workspace-support.js. Hier stand der falsche Name, und das
Bild landete neben der Tuer statt dahinter. */
const ziel = join(DATEN_ORDNER, "support-bilder");
mkdirSync(ziel, { recursive: true }); mkdirSync(ziel, { recursive: true });
writeFileSync(join(ziel, "probe.png"), png); writeFileSync(join(ziel, "probe.png"), png);
} catch (f) { console.log("Bild nicht abgelegt:", f?.message); } } catch (f) { console.log("Bild nicht abgelegt:", f?.message); }
+59 -2
View File
@@ -118,6 +118,41 @@ const anfrage = (pfad, art, koerper, keks) => new Promise((fertig, schief) => {
if (koerper) a.write(JSON.stringify(koerper)); if (koerper) a.write(JSON.stringify(koerper));
a.end(); a.end();
}); });
/** Eine Rueckmeldung des Melders -- UEBER DEN KOPF, nicht als JSON.
*
* ==== DIESE MESSUNG HAT ZWEI TAGE LANG NICHTS GEMESSEN ===========
*
* Am 02.10.2026 ist `/rueckmeldung` auf den Weg umgestellt worden,
* den auch das Melden nimmt: Urteil und Text reisen im KOPF
* (`x-geht`, `x-text`, prozentkodiert), der Rumpf ist fuer die
* Bilder da. Diese Datei schickte weiter JSON -- der Server fand
* also kein Urteil und keinen Text und antwortete mit 400.
*
* GEMERKT HAT ES NIEMAND, weil die Messung keine Rueckgabewerte
* prueft: Sie macht Bilder. Auf dem Bild stand danach eine Runde
* statt vier, und das sieht aus wie ein Ergebnis. Aufgefallen ist
* es erst, als die Bilder vier Rundenfarben zeigen sollten.
*
* Eine Messung, die nach einem Umbau stillschweigend etwas anderes
* misst, ist schlimmer als keine -- man glaubt ihr. */
const rueckmelden = (id, geht, satz, keks) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: `/workspace/api/support/${id}/rueckmeldung`,
method: "POST",
headers: {
Host: CREW, Cookie: keks,
"Content-Type": "application/octet-stream",
"x-geht": geht ? "ja" : "nein",
"x-text": encodeURIComponent(satz || ""),
},
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, text: t }));
});
a.on("error", schief);
a.end();
});
const anmelden = async (rolle, code) => { const anmelden = async (rolle, code) => {
const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code }); const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); } if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); }
@@ -200,12 +235,34 @@ try {
await blicken("melder-handy", kModi, 412, 915); await blicken("melder-handy", kModi, 412, 915);
/* --- Runde 2: „noch nicht", dann behoben, dann bestaetigt --------- */ /* --- Runde 2: „noch nicht", dann behoben, dann bestaetigt --------- */
await anfrage(`/workspace/api/support/${dran}/rueckmeldung`, "POST", const r2 = await rueckmelden(dran, false,
{ geht: false, text: "Der Knopf ja – aber jetzt springt die Seite nach oben." }, kModi); "Der Knopf ja – aber jetzt springt die Seite nach oben.", kModi);
if (r2.code !== 200) console.log(` !! Rueckmeldung ging nicht: ${r2.code} ${r2.text}`);
await anfrage(`/workspace/api/support/${dran}`, "PATCH", await anfrage(`/workspace/api/support/${dran}`, "PATCH",
{ stand: "erledigt", antwort: "Das Springen kam vom Nachladen. Ist raus." }, kHand); { stand: "erledigt", antwort: "Das Springen kam vom Nachladen. Ist raus." }, kHand);
await blicken("melder-runde2", kModi, 1280, 1000); await blicken("melder-runde2", kModi, 1280, 1000);
/* ==== VIER RUNDEN -- WEIL ES VIER FARBEN SIND (03.10.2026) =======
Filipe: „jede runde soll auch immer eine spezielle farbe haben …
damit man einen viel besseren und krasseren ueberblick hat."
Mit zwei Runden sieht man zwei Toene und kann nicht sagen, ob es
eine Reihe ist. Vier ist die Zahl, nach der sich die Farben
wiederholen (siehe `rundenTon` in support.js) -- also genau die
Zahl, bei der man die Regel sieht statt sie zu glauben. */
for (const [wort, satz] of [
["Jetzt laedt die Liste nicht mehr nach.", "Und jetzt ist der Knopf doppelt."],
["Der doppelte Knopf ist raus.", "Jetzt stimmt die Reihenfolge nicht."],
]) {
const rr = await rueckmelden(dran, false, satz, kModi);
if (rr.code !== 200) console.log(` !! Rueckmeldung ging nicht: ${rr.code} ${rr.text}`);
await anfrage(`/workspace/api/support/${dran}`, "PATCH",
{ stand: "erledigt", antwort: wort }, kHand);
}
await blicken("melder-vierrunden", kModi, 1280, 1100);
await blicken("melder-vierrunden-handy", kModi, 412, 915);
console.log(`\n(Die offene Meldung #${offen} bleibt unangetastet stehen –` console.log(`\n(Die offene Meldung #${offen} bleibt unangetastet stehen –`
+ " so sieht man beide Sorten nebeneinander.)"); + " so sieht man beide Sorten nebeneinander.)");
} finally { } finally {
+198
View File
@@ -0,0 +1,198 @@
/* =====================================================================
WAS PASSIERT BEIM TEILEN WIRKLICH? (05.10.2026)
Filipe: "ich lade video hoch und es erscheint immer noch nicht",
dazu auf Nachfrage: die Seite bleibt haengen, es kommt KEINE
Meldung -- und auf der Crew-Adresse ist das Brett voll (49 Videos).
Gemessen war vorher schon: Server unveraendert, Sitzung gueltig,
TikTok antwortet (auch auf Kurzlinks), und im Protokoll steht heute
kein einziges `video_eingelesen` -- die Anfrage erreicht den Server
also gar nicht. `los()` in teilen.js macht keine Netzanfrage; wenn
die Seite dort stehenbleibt, ist das Skript gestolpert.
Diese Messung spielt genau das nach: Teilen-Seite mit einem Link
aufrufen, Browsermeldungen mitschreiben, und nachsehen, ob die
Ladezeile verschwindet. Dann denselben Weg bis zum Absenden.
KEIN PRUEFLAUF, sondern eine Messung -- sie stellt fest, was ist,
und sagt nicht, was sein soll.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-teilen-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "mess-teilen");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(180_000, "mess-teilen");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }),
}).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
d.close();
}
const { chromium } = await import("playwright");
const browser = await chromium.launch();
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
});
const seite = await kontext.newPage();
const klagen = [];
seite.on("console", (m) => { if (m.type() === "error") klagen.push("Konsole: " + m.text()); });
seite.on("pageerror", (f) => klagen.push("ABSTURZ: " + String(f?.message || f)));
seite.on("requestfailed", (r) => klagen.push("Anfrage gescheitert: " + r.url().replace(BASIS, "")));
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 20000 });
/* ---- Genau so, wie die App beim Teilen aufruft ---------------------- */
const LINK = "https://vm.tiktok.com/ZN8h3hbHr/";
await seite.goto(`${BASIS}/workspace/teilen.html?url=${encodeURIComponent(LINK)}`,
{ waitUntil: "domcontentloaded" });
await seite.waitForTimeout(1500);
const lage = await seite.evaluate(() => {
const z = (id) => document.getElementById(id);
return {
ladezeileWeg: z("ladezeile")?.hidden === true,
wahlDa: z("wahl")?.hidden === false,
knoepfeDa: z("knoepfe")?.hidden === false,
fundDa: z("fund")?.hidden === false,
linkText: z("url-text")?.textContent || "",
knopfText: z("uebernehmen")?.textContent || "",
fehlerText: z("fehler")?.textContent || "",
};
});
console.log("\n=== Die Teilen-Seite nach dem Aufruf ===");
console.log(` Ladezeile verschwunden : ${lage.ladezeileWeg}`);
console.log(` Auswahl sichtbar : ${lage.wahlDa}`);
console.log(` Knopfreihe sichtbar : ${lage.knoepfeDa}`);
console.log(` Link erkannt : ${lage.fundDa} "${lage.linkText}"`);
console.log(` Knopf : "${lage.knopfText.trim()}"`);
console.log(` Fehlerzeile : "${lage.fehlerText.trim()}"`);
console.log(` Browsermeldungen : ${klagen.length ? klagen.join(" | ") : "keine"}`);
/* ---- Und jetzt absenden -------------------------------------------- */
if (lage.knoepfeDa) {
const antwort = seite.waitForResponse(
(r) => /\/api\/bereich\/[a-z]+\/video$/.test(r.url()), { timeout: 20000 }).catch(() => null);
await seite.click("#uebernehmen");
const a = await antwort;
await seite.waitForTimeout(1500);
const danach = await seite.evaluate(() => ({
fertig: document.getElementById("fertig")?.hidden === false,
satz: document.getElementById("fertig-satz")?.textContent || "",
fehler: document.getElementById("fehler")?.textContent || "",
knopf: document.getElementById("uebernehmen")?.textContent || "",
}));
console.log("\n=== Nach dem Absenden ===");
console.log(` Antwort vom Server : ${a ? a.status() : "KEINE -- es ging nichts hinaus"}`);
console.log(` Erfolg sichtbar : ${danach.fertig} "${danach.satz.trim()}"`);
console.log(` Fehlerzeile : "${danach.fehler.trim()}"`);
console.log(` Knopf steht auf : "${danach.knopf.trim()}"`);
console.log(` Browsermeldungen : ${klagen.length ? klagen.join(" | ") : "keine"}`);
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege WHERE bereich = 'highlight'").get().n;
d.close();
console.log(` Eintraege im Brett : ${n}`);
} else {
console.log("\n Die Seite ist gar nicht erst fertig geworden -- kein Absenden moeglich.");
}
/* =====================================================================
GEGENPROBE: Was passiert, wenn der Aufbau WIRKLICH stolpert?
Ohne sie waere die Sicherung von heute eine Behauptung. Gepruefft
wird am haerteren Fall: Ein Element, das `los()` sofort anfasst,
wird aus der Seite entfernt, bevor das Skript laeuft. Vorher blieb
die Seite danach fuer immer auf "wird geladen" stehen -- genau
Filipes Bild.
===================================================================== */
{
const s2 = await kontext.newPage();
const klagen2 = [];
s2.on("pageerror", (f) => klagen2.push(String(f?.message || f)));
/* DIE ZERSTOERUNG MUSS SICHER ANKOMMEN (berichtigt 05.10.2026).
Der erste Versuch tauschte die Kennung im HTML unterwegs aus --
und kam nie an: Die Seite lud normal, der Browser meldete nichts,
und die Messung schloss daraus "die Sicherung greift nicht".
Eine Gegenprobe, die ihren eigenen Schaden nicht anrichtet,
misst gar nichts.
Jetzt wird `document.getElementById` ersetzt, bevor irgendein
Skript laeuft -- genau das, was `$` in teilen.js benutzt. Damit
wirft `los()` zuverlaessig an derselben Stelle wie bei einem
echten Fehler. */
await s2.addInitScript(() => {
const echt = document.getElementById.bind(document);
document.getElementById = (id) => {
if (id === "bretter") throw new Error("Probe: Element fehlt");
return echt(id);
};
});
await s2.goto(`${BASIS}/workspace/teilen.html?url=${encodeURIComponent(LINK)}`,
{ waitUntil: "domcontentloaded" });
await s2.waitForTimeout(1200);
const kaputt = await s2.evaluate(() => {
const z = (id) => document.getElementById(id);
return {
ladezeileWeg: z("ladezeile")?.hidden === true,
meldung: (z("fehler")?.textContent || "").trim(),
eingabeDa: z("haendisch")?.hidden === false,
knopfDa: z("uebernehmen") !== null,
};
});
console.log("\n=== Gegenprobe: Seite absichtlich zerstoert ===");
console.log(` Ladezeile verschwunden : ${kaputt.ladezeileWeg}`);
console.log(` Meldung sichtbar : "${kaputt.meldung.slice(0, 96)}"`);
console.log(` Einfuegefeld offen : ${kaputt.eingabeDa}`);
console.log(` (Browser meldete : ${klagen2.length ? klagen2[0].slice(0, 60) : "nichts"})`);
console.log(kaputt.ladezeileWeg && kaputt.meldung && kaputt.eingabeDa
? " -> Die Seite sagt, dass etwas kaputt ist, und bietet den Weg von Hand an."
: " -> SIE HAENGT IMMER NOCH. Die Sicherung greift nicht.");
await s2.close();
}
await kontext.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(0);
+30 -7
View File
@@ -697,33 +697,56 @@ melde("\n=== Die Kachel ===");
Regeln koennen vollstaendig in der Antwort stehen und trotzdem Regeln koennen vollstaendig in der Antwort stehen und trotzdem
nirgends gezeichnet werden -- ein vergessener Aufruf in karte() nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
reicht, und niemand merkt es, weil die Karte ja da ist. */ reicht, und niemand merkt es, weil die Karte ja da ist. */
/* ==== AUF DIE NEUE KARTE UMGESTELLT (03.10.2026) ================
Die Eventkarte wurde umgebaut (siehe pruef-eventkarte.mjs): Buehne
mit Titel auf dem Banner, Zeitraum einmal statt dreimal, Aufgaben
als Liste zum Abhaken. Dabei haben die Bausteine neue Namen
bekommen -- `.event-zeitraum__marke` heisst jetzt `.ev-noch`,
`.event-block__text` ist zu einer Liste aus `.ev-punkt__text`
geworden.
DIESE PRUEFUNG WURDE DESHALB NICHT GELOESCHT, SONDERN NACHGEZOGEN:
Sie sagt weiterhin dieselben FUENF Dinge zu, nur an der neuen
Stelle. Eine Pruefung wegzunehmen, weil sie nach einem Umbau rot
wird, senkt die Zahl und beweist danach nichts mehr
(Projektgedaechtnis, 28.08.2026: 789 statt 804 Pruefungen, und
acht davon fielen still weg).
WAS SICH INHALTLICH AENDERT: Der Zustand steht nicht mehr als
"laeuft bis TT.MM.JJJJ" da, sondern als "noch N Tage" -- dieselbe
Aussage, nur beantwortet sie jetzt die Frage, die man wirklich
hat. Geprueft wird deshalb auf ein WORT (nicht nur Farbe), nicht
mehr auf einen bestimmten Satz. */
const event = await seite.$$eval(".eintrag-karte", (karten) => { const event = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion"); const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
if (!k) return null; if (!k) return null;
return { return {
stand: k.dataset.event, stand: k.dataset.event,
marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(), marke: k.querySelector(".ev-noch")?.textContent.trim(),
spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(), spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()), schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()), texte: [...k.querySelectorAll(".ev-punkt__text")].map((s) => s.textContent.trim()),
}; };
}); });
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite"); ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`); ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`);
ok(/läuft bis/.test(event?.marke || ""), /* Ein Wort, kein leerer Platzhalter -- und es muss zum Zustand
passen: "noch N Tage" bzw. "heute ist der letzte Tag". */
ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.test(event?.marke || ""),
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`); `der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`); ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
ok(event?.schilder.join(",") === "Aufgaben,Regeln", ok(event?.schilder.join(",") === "Aufgaben,Regeln",
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`); `beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"), ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
"und tragen wirklich ihren Inhalt"); `und tragen wirklich ihren Inhalt (${JSON.stringify(event?.texte)})`);
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon /* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
haben. Sonst bewiese der Block oben nur, dass die Elemente haben. Sonst bewiese der Block oben nur, dass die Elemente
existieren -- nicht, dass sie zur richtigen Art gehoeren. */ existieren -- nicht, dass sie zur richtigen Art gehoeren. */
const schulung = await seite.$$eval(".eintrag-karte", (karten) => { const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung"); const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung");
return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null; return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".ev-block").length } : null;
}); });
ok(schulung && !schulung.event && schulung.bloecke === 0, ok(schulung && !schulung.event && schulung.bloecke === 0,
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`); `eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
+246
View File
@@ -0,0 +1,246 @@
/* =====================================================================
ANGEMELDET BLEIBEN, BIS MAN SICH SELBST ABMELDET (06.10.2026)
=====================================================================
Filipe: „ich will dass wenn sich jemand bei der workspace seite
anmeldet, angemeldet bleibt bis er sich selbst abmeldet."
---------------------------------------------------------------------
WAS DIE MESSUNG VORHER ERGEBEN HAT -- und warum die naheliegende
Antwort die falsche gewesen waere
Die naheliegende Antwort waere gewesen, die Sitzung zu verlaengern.
Sie war aber nie zu kurz:
* Der Keks gilt 180 Tage und verlaengert sich beim Benutzen.
* In der echten Datenbank lief KEINE der 28 Sitzungen vor Maerz
2027 ab.
* Am echten Browser: Er uebersteht Neuladen, den Service Worker
und das Schliessen des Browsers.
Trotzdem meldeten sich dieselben Geraete staendig neu an --
dieselbe IP, dieselbe Browserkennung, einmal zweimal innerhalb
einer Minute. Der Grund lag woanders: `/workspace/` zeigte die
Anmeldung, OHNE zu fragen, ob schon jemand angemeldet ist. Das ist
die Adresse, die man tippt und weitergibt.
Haette ich die Sitzungsdauer erhoeht, waere der Fehler geblieben
und die Sicherheit schlechter geworden.
---------------------------------------------------------------------
DIE WICHTIGSTE PRUEFUNG IST DIE ZAHL DER SITZUNGEN
„Man bleibt angemeldet" laesst sich leicht vortaeuschen: Wer bei
jedem Besuch stillschweigend neu anmeldet, sieht auch nie ein
Codefeld. Deshalb wird hier durchgehend gezaehlt, wie viele
Sitzungen in der Datenbank stehen. Bleibt es bei EINER, ist
niemand neu angemeldet worden.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-ang-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Anmeldepruefung");
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";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
await import("./index.js");
notbremse(240_000, "pruef-angemeldet-bleiben");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function braucht(bedingung, was) {
if (bedingung) return;
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
/* ---------- Ein Zugang ------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, new Date().toISOString());
const CODE = "CODE-BLEIB-0001";
const salz = randomBytes(16).toString("hex");
const h = scryptSync(CODE, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run("Bleibt", "scout", h, salz, 32768,
createHmac("sha256", kenn).update(CODE).digest("hex"), "2025-10-01T08:00:00Z");
d.close();
const sitzungen = () => {
const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const n = db2.prepare("SELECT COUNT(*) n FROM sitzungen").get().n;
db2.close();
return n;
};
/* ---------- Ein Browser MIT Profil auf der Platte -----------------
Nur so ueberlebt ein Keks das Schliessen des Browsers. Ein
gewoehnlicher Kontext vergisst alles -- dann maesse diese Pruefung
ihre eigene Einrichtung statt der Software. */
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const profil = join(ordner, "profil");
const BASIS = `http://127.0.0.1:${PORT}`;
const auf = () => pw.chromium.launchPersistentContext(profil, {
viewport: { width: 412, height: 800 },
});
/** Wohin fuehrt diese Adresse -- und ist die Seite danach sichtbar? */
async function hin(seite, pfad) {
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
/* Laenger als die feste Frist im Skript (900 ms): Sonst misst diese
Pruefung den Zwischenzustand und nicht das Ergebnis. */
await seite.waitForTimeout(1300);
return {
weg: seite.url().replace(BASIS, ""),
sichtbar: await seite.evaluate(() => getComputedStyle(document.body).visibility),
codefeld: await seite.evaluate(() => !!document.getElementById("code")),
};
}
/* =====================================================================
1 · OHNE ANMELDUNG
===================================================================== */
melde("\n=== Ohne Anmeldung ===");
let kontext = await auf();
let seite = await kontext.newPage();
let r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/", `die Anmeldung bleibt stehen (${r.weg})`);
ok(r.codefeld, "das Codefeld ist da");
/* DIE WICHTIGSTE ZEILE DIESES ABSCHNITTS. Die Verdeckung waehrend der
Abfrage darf NIE haengenbleiben -- sonst koennte sich niemand mehr
anmelden, und zwar genau dann, wenn das Netz schlecht ist. */
ok(r.sichtbar === "visible", `und die Seite ist sichtbar (${r.sichtbar})`);
ok(sitzungen() === 0, `noch keine Sitzung in der Datenbank (${sitzungen()})`);
/* =====================================================================
2 · ANMELDEN
===================================================================== */
melde("\n=== Anmelden ===");
await seite.click('.rolle[data-rolle="scout"]');
await seite.fill("#code", CODE);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
ok(sitzungen() === 1, `genau eine Sitzung entstanden (${sitzungen()})`);
const keks = (await kontext.cookies()).find((c) => c.name === "dfw_sitzung");
braucht(!!keks, "der Sitzungskeks wurde gar nicht gesetzt");
const tage = Math.round((keks.expires * 1000 - Date.now()) / 86400000);
ok(tage > 170, `der Keks ueberlebt das Schliessen des Browsers (${tage} Tage)`);
/* =====================================================================
3 · DIE ADRESSE, DIE MAN TIPPT
=====================================================================
Genau hier lag der Fehler: `/workspace/` zeigte die Anmeldung,
obwohl die Sitzung gueltig war.
===================================================================== */
melde("\n=== Die Adresse, die man tippt ===");
for (const runde of [1, 2, 3]) {
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/start.html",
`Aufruf ${runde}: fuehrt direkt zur Startseite (${r.weg})`);
ok(!r.codefeld, `Aufruf ${runde}: kein Codefeld`);
}
ok(sitzungen() === 1,
`nach drei Aufrufen immer noch EINE Sitzung (${sitzungen()})`
+ " — niemand wurde stillschweigend neu angemeldet");
/* =====================================================================
4 · BROWSER ZU UND WIEDER AUF
===================================================================== */
melde("\n=== Browser schliessen und neu oeffnen ===");
await kontext.close();
kontext = await auf();
seite = await kontext.newPage();
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/start.html", `auch danach direkt zur Startseite (${r.weg})`);
ok(sitzungen() === 1, `und immer noch eine einzige Sitzung (${sitzungen()})`);
/* =====================================================================
5 · ABMELDEN -- und dann muss Schluss sein
=====================================================================
Die Gegenprobe zu allem davor. Ohne sie hiesse „man bleibt
angemeldet" moeglicherweise „man kommt gar nicht mehr heraus".
===================================================================== */
melde("\n=== Abmelden ===");
await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const abmelden = await seite.$("#abmelden");
braucht(!!abmelden, "der Abmelden-Knopf ist nicht da");
await abmelden.click();
/* ABMELDEN FRAGT ERST NACH (kopf.js, seit 19.09.2026) -- und das aus
gutem Grund: Zum Wiederanmelden braucht man den persoenlichen
Zugangscode, und den bekommt jeder nur einmal.
Beim ersten Lauf hat diese Pruefung nur geklickt und dann gemeldet
„die Sitzung ist nicht weg". Das war kein Befund am Code, sondern
eine Pruefung, die auf halbem Weg stehen blieb. */
await seite.waitForTimeout(600);
/* GENAU der Bestaetigungsknopf. Beim ersten Anlauf stand hier eine
Auswahl mit Komma, und die traf den erstbesten `.knopf` im Dialog
-- das war „Bild waehlen", unsichtbar, und der Klick lief 30
Sekunden in eine Zeitueberschreitung. */
const bestaetigen = await seite.$('dialog[open] [data-teil="ja"]');
braucht(!!bestaetigen, "die Rueckfrage beim Abmelden kam nicht");
await bestaetigen.click();
await seite.waitForTimeout(1800);
ok(sitzungen() === 0, `die Sitzung ist wirklich weg (${sitzungen()})`);
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/", `danach steht die Anmeldung da (${r.weg})`);
ok(r.codefeld, "mit Codefeld");
ok(r.sichtbar === "visible", `und sichtbar (${r.sichtbar})`);
/* Und sie bleibt stehen -- kein Hin und Her zwischen zwei Seiten. */
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/" && r.codefeld,
"auch beim zweiten Aufruf — kein Hin und Her");
/* =====================================================================
6 · GEGENPROBE: MISST DIESE PRUEFUNG UEBERHAUPT ETWAS?
=====================================================================
Wenn „fuehrt zur Startseite" auch ohne gueltige Sitzung herauskaeme,
waere der ganze Abschnitt 3 wertlos. Also noch einmal anmelden, den
Keks von Hand wegnehmen und nachsehen, ob die Messung es merkt.
===================================================================== */
melde("\n=== Gegenprobe ===");
await seite.goto(`${BASIS}/workspace/`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
await seite.click('.rolle[data-rolle="scout"]');
await seite.fill("#code", CODE);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/start.html", "angemeldet: fuehrt zur Startseite");
await kontext.clearCookies();
r = await hin(seite, "/workspace/");
ok(r.weg === "/workspace/" && r.codefeld,
"ohne Keks: die Anmeldung — die Messung kann also auch Nein sagen");
await kontext.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);
+50 -14
View File
@@ -63,6 +63,24 @@ import { fileURLToPath } from "node:url";
const REPO = join(dirname(fileURLToPath(import.meta.url)), ".."); const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
const lies = (p) => readFileSync(join(REPO, p), "utf8"); const lies = (p) => readFileSync(join(REPO, p), "utf8");
/* ---- NUR DER CODE, OHNE DIE ERZAEHLUNG DRUMHERUM (03.10.2026) -------
Diese Datei prueft Quelltext mit Mustern. Das ist schnell und
braucht keinen Server -- hat aber eine Luecke, die heute
aufgefallen ist: Die Pruefung "und zwar an derselben Bedingung wie
die Nachtruhe" war gruen, OBWOHL die Zeile aus dem Code
verschwunden war. Sie stand nur noch in einem Kommentar, der die
alte Fassung zitiert -- und ein Muster sieht den Unterschied
nicht.
Das ist die dritte Sorte gruener Haken: nicht uebersprungen, nicht
falsch geprueft, sondern am Beleg vorbei. Deshalb werden
Kommentare vorher entfernt. Was hier geprueft wird, muss laufender
Code sein. */
const nurCode = (t) => t
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:])\/\/.*/g, "$1");
let ok = 0, fehl = 0; let ok = 0, fehl = 0;
const pruef = (name, bedingung, zusatz = "") => { const pruef = (name, bedingung, zusatz = "") => {
if (bedingung) { ok++; console.log(` OK ${name}`); } if (bedingung) { ok++; console.log(` OK ${name}`); }
@@ -104,29 +122,47 @@ pruef("und für alles andere bleibt es ruhig",
/* ===================================================================== /* =====================================================================
2. DER VERSAND — Dringlichkeit und Haltbarkeit 2. DER VERSAND — Dringlichkeit und Haltbarkeit
===================================================================== */ ===================================================================== */
const krypto = lies("server/workspace-push-krypto.js"); const krypto = nurCode(lies("server/workspace-push-krypto.js"));
const versand = lies("server/workspace-push.js"); const versand = nurCode(lies("server/workspace-push.js"));
pruef("der Transport kennt überhaupt eine Dringlichkeit", pruef("der Transport kennt überhaupt eine Dringlichkeit",
/dringend\s*=\s*false\s*\}\s*=\s*\{\}/.test(krypto), /eilig\s*=\s*false/.test(krypto),
"vorher stand Urgency fest auf \"normal\""); "vorher stand Urgency fest auf \"normal\"");
pruef("ein dringender Push geht mit Urgency: high hinaus", pruef("ein eiliger Push geht mit Urgency: high hinaus",
/Urgency:\s*dringend\s*\?\s*"high"\s*:\s*"normal"/.test(krypto)); /Urgency:\s*eilig\s*\?\s*"high"\s*:\s*"normal"/.test(krypto));
pruef("und verfällt, wenn der Anruf vorbei ist", pruef("und verfällt, wenn der Anruf vorbei ist",
/TTL:\s*String\(dringend\s*\?\s*Math\.min\(ttl,\s*\d+\)/.test(krypto), /TTL:\s*String\(kurzlebig\s*\?\s*Math\.min\(ttl,\s*\d+\)/.test(krypto),
"sonst ploppt ein Anruf von vor einer Stunde nachträglich auf"); "sonst ploppt ein Anruf von vor einer Stunde nachträglich auf");
pruef("der Anruf wird als dringend verschickt", pruef("der Anruf wird eilig UND kurzlebig verschickt",
/schicken\(a,\s*inhalt,\s*paar,\s*ABSENDER,\s*\{\s*dringend\s*\}\)/.test(versand)); /schicken\(a,\s*inhalt,\s*paar,\s*ABSENDER,\s*\{\s*eilig,\s*kurzlebig\s*\}\)/.test(versand));
/* DIESELBE BEDINGUNG WIE BEI DER RUHEZEIT. „Darf das nachts stören?" /* ---- ZWEI FRAGEN, ZWEI FELDER (geändert 03.10.2026) -----------------
und „darf das warten?" haben dieselbe Antwort -- zwei getrennte
Bedingungen wären die, die beim nächsten Nachschärfen auseinander Hier stand bis heute das Gegenteil: „Darf das nachts stören?" und
laufen. */ „darf das warten?" hätten dieselbe Antwort, zwei getrennte
pruef("und zwar an derselben Bedingung wie die Nachtruhe", Bedingungen wären die, die auseinanderlaufen. Die Vorhersage war
/const dringend = art === "test" \|\| art === "anruf";/.test(versand)); richtig, nur in die andere Richtung — sie laufen auseinander, WEIL
es zwei Fragen sind:
Chat um 3 Uhr nachts -> nicht stören (Ruhezeit greift)
Chat um 14 Uhr -> nicht warten (eilig)
Genau diese Gleichsetzung hat Dienes Meldung verursacht: Weil der
Chat nicht nachts stören darf, bekam er auch tagsüber
`Urgency: normal` — und Android legt das beiseite, bis jemand die
App öffnet. Das Zusammenlegen war die Ursache, nicht der Schutz.
Auseinanderlaufen können die beiden trotzdem nicht: Sie stehen als
`eilig` und `ruhe` in DERSELBEN Zeile derselben Artenliste. */
pruef("der Anruf ist eilig und kurzlebig, beides in einer Zeile",
/\{\s*schluessel:\s*"anruf",\s*eilig:\s*true,[^}]*ruhe:\s*"nie"/.test(versand));
pruef("und die Dringlichkeit wird aus der Artenliste abgeleitet",
/const eilig = eiligRegel\(art\);/.test(versand),
"nicht aus einer zweiten Aufzählung daneben");
/* ===================================================================== /* =====================================================================
3. DER WEG DORTHIN — wird beim Anruf überhaupt geweckt? 3. DER WEG DORTHIN — wird beim Anruf überhaupt geweckt?
+202
View File
@@ -75,6 +75,9 @@ const keks = async (rolle, code) => {
}; };
const kScout = await keks("scout", "CODE-SCOU-0001"); const kScout = await keks("scout", "CODE-SCOU-0001");
const kTili = await keks("creator", "CODE-ANNA-0001"); const kTili = await keks("creator", "CODE-ANNA-0001");
/* DogFather darf als Einziger „dauerhaft" setzen -- deshalb braucht
diese Pruefung seit dem 02.10.2026 auch seinen Zugang. */
const kDogi = await keks("admin", "CODE-DOGI-0001");
const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); const ruf = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } });
const senden = (pfad, k, art, koerper) => ruf(pfad, k, { const senden = (pfad, k, art, koerper) => ruf(pfad, k, {
method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) }); method: art, headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper || {}) });
@@ -234,6 +237,119 @@ console.log("\n=== Von der Vorlage zur Aufgabe");
`alle ${sollen} einer Stufe auf einmal (${vorher} → ${nachher})`); `alle ${sollen} einer Stufe auf einmal (${vorher} → ${nachher})`);
} }
/* ---------- 2b. Frist, „dauerhaft" und eine Anmerkung -------------------- */
/* =========================================================================
VanVan im Support, Meldung #6, zweite Haelfte: „Bei den Vorlagen laesst
sich weder die Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch."
GEPRUEFT WIRD AM SERVER, nicht am Fenster. Das Fenster bietet die drei
Felder an; was ankommt, ist trotzdem eine Zeichenkette aus dem Netz.
========================================================================= */
console.log("\n=== Frist, dauerhaft und Anmerkung");
{
const in20 = (() => {
const d = new Date(Date.now() + 20 * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
})();
/* ---- Eine eigene Frist --------------------------------------------- */
const mitFrist = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 0,
creator_id: idTili, frist: in20 });
const nachFrist = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[0].titel);
ok(mitFrist.ok && nachFrist?.frist === in20,
`eine eigene Frist kommt durch (${nachFrist?.frist}, gewollt ${in20})`);
/* GEGENPROBE: Die Vorlage selbst sieht eine ANDERE Frist vor. Ohne
diesen Vergleich waere die Zeile oben auch dann gruen, wenn der
Server die Angabe verwirft und zufaellig dasselbe herauskommt. */
ok(V.technik.fort[0].tage !== 20,
` und die Vorlage selbst saehe ${V.technik.fort[0].tage} Tage vor — `
+ "die Angabe hat also wirklich gewirkt");
/* ---- Eine Anmerkung wird zur ersten Notiz --------------------------- */
const satz = "Bitte zuerst mit Rieke absprechen.";
const mitNotiz = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 1,
creator_id: idTili, anmerkung: satz });
const mitNotizA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[1].titel);
ok(mitNotiz.ok && mitNotizA?.notizen === 1,
`die Anmerkung wird eine Notiz an der Aufgabe (${mitNotizA?.notizen})`);
const nz = await (await ruf(`/workspace/api/aufgaben/${mitNotizA?.id}/notizen`, kTili)).json();
ok((nz.notizen || []).length === 1 && nz.notizen[0].text === satz,
` und zwar woertlich („${(nz.notizen || [])[0]?.text}")`);
/* SIE TRAEGT, VON WEM SIE STAMMT -- in die Beschreibung geschrieben
waere das verloren, und beim naechsten Mal haette dieselbe Vorlage
einen anderen Text. */
ok(!!(nz.notizen || [])[0]?.von,
` mit dem Namen dessen, der sie geschrieben hat (${(nz.notizen || [])[0]?.von})`);
/* ---- OHNE ANGABE AENDERT SICH NICHTS -------------------------------- */
const ohne = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "technik", stufe: "fort", nr: 2,
creator_id: idTili });
const ohneA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.technik.fort[2].titel);
ok(ohne.ok && ohneA?.notizen === 0 && !ohneA?.dauerhaft && !!ohneA?.frist,
`ohne Angabe bleibt alles wie bisher (${ohneA?.notizen} Notizen, `
+ `dauerhaft ${ohneA?.dauerhaft || 0}, Frist ${ohneA?.frist})`);
/* ---- „Dauerhaft" darf nur, wer verteilen darf ------------------------ */
const alsScout = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "fort", nr: 0,
creator_id: idTili, dauerhaft: true });
ok(alsScout.status === 403,
`ein Scout kann keine dauerhafte Aufgabe anlegen (${alsScout.status})`);
/* UND ES ENTSTEHT AUCH KEINE OHNE DEN HAKEN. Eine Absage, die
trotzdem etwas anlegt, waere schlimmer als gar keine. */
const nixDa = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.filter((x) => x.titel === V.live.fort[0].titel).length;
ok(nixDa === 0, ` und die Absage legt auch nichts an (${nixDa})`);
const alsChef = await senden("/workspace/api/vorlagen/uebernehmen", kDogi, "POST",
{ art: "aufgabe", bereich: "live", stufe: "fort", nr: 0,
creator_id: idTili, dauerhaft: true, frist: in20 });
const dauerA = ((await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [])
.find((x) => x.titel === V.live.fort[0].titel);
ok(alsChef.ok && !!dauerA?.dauerhaft,
`DogFather kann es (${alsChef.status}, dauerhaft ${dauerA?.dauerhaft})`);
/* UND DANN GIBT ES KEINE FRIST -- obwohl eine mitgeschickt wurde.
Eine dauerhafte Aufgabe mit Frist waere ab morgen fuer immer
ueberfaellig, und eine Warnung, die immer kommt, ist keine mehr.
Dieselbe Regel steht im Aenderungsweg; haette sie hier gefehlt,
gaebe es zwei Antworten auf dieselbe Frage. */
ok(alsChef.ok && !dauerA?.frist,
` und sie hat KEINE Frist, auch wenn eine mitkam (${dauerA?.frist ?? "keine"})`);
/* ---- Was der Server nicht annimmt ----------------------------------- */
const krumm = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "morgen" });
ok(krumm.status === 400, `„morgen" ist kein Datum (${krumm.status})`);
const unmoeglich = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "2026-13-45" });
ok(unmoeglich.status === 400, `den 45. des 13. Monats gibt es nicht (${unmoeglich.status})`);
const vorbei = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0,
creator_id: idTili, frist: "2020-01-01" });
ok(vorbei.status === 400,
`eine Frist in der Vergangenheit wird abgelehnt (${vorbei.status}) — sonst waere `
+ "die Aufgabe in dem Moment rot, in dem sie entsteht");
/* GEGENPROBE ZU DEN DREI ABSAGEN: Dieselbe Vorlage OHNE Frist geht
durch. Ohne sie koennte die Route alles ablehnen und waere dreimal
gruen. */
const dochGut = await senden("/workspace/api/vorlagen/uebernehmen", kScout, "POST",
{ art: "aufgabe", bereich: "live", stufe: "meister", nr: 0, creator_id: idTili });
ok(dochGut.ok, `Gegenprobe: dieselbe Vorlage ohne Frist geht durch (${dochGut.status})`);
}
/* ---------- 3. Gegenproben ------------------------------------------------ */ /* ---------- 3. Gegenproben ------------------------------------------------ */
console.log("\n=== Gegenproben"); console.log("\n=== Gegenproben");
{ {
@@ -416,6 +532,92 @@ console.log("\n=== Auf der Aufgabenseite");
`die übrigen ${m.gesamt - m.markiert} sind nicht markiert`); `die übrigen ${m.gesamt - m.markiert} sind nicht markiert`);
} }
/* ---- ANPASSEN: FRIST UND ANMERKUNG AUS DEM FENSTER (02.10.2026) ---
VanVan im Support, Meldung #6: „Bei den Vorlagen laesst sich weder
die Frist anpassen noch ‚dauerhaft' einstellen, und eine Anmerkung
fehlt auch."
DIE SERVERSEITE STEHT WEITER OBEN -- hier geht es um den Weg, den
SIE nimmt: Knopf, Fenster, Felder, und ob am Ende wirklich die
Frist dransteht, die sie eingetragen hat. Ein Server, der die
Angabe annimmt, hilft niemandem, dessen Fenster sie nicht
mitschickt. */
{
const anpassen = seite.locator("#vorlagenbrett .vorlagenbrett__anpassen").first();
ok(await anpassen.count() > 0, "jede Karte hat einen „Anpassen …\"-Knopf");
await anpassen.click();
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 8000 });
const felder = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.nachfrage");
const dz = dlg.querySelector('[data-teil="datumzeile"]');
const hz = dlg.querySelector('[data-teil="hakenzeile"]');
const gz = dlg.querySelector('[data-teil="grundzeile"]');
return {
datum: !!dz && !dz.hidden,
datumWert: dlg.querySelector("#nachfrage-datum")?.value || "",
datumMin: dlg.querySelector("#nachfrage-datum")?.min || "",
haken: !!hz && !hz.hidden,
grund: !!gz && !gz.hidden,
};
});
ok(felder.datum && felder.grund,
`das Fenster hat ein Datum und ein Anmerkungsfeld (${felder.datum}, ${felder.grund})`);
/* DIE VORGABE IST DIE FRIST DER VORLAGE -- ein leeres Feld hiesse
„rechne selbst, in wie vielen Tagen", und genau das Rechnen
nimmt die Vorlage einem ab. */
ok(/^\d{4}-\d{2}-\d{2}$/.test(felder.datumWert) && felder.datumWert > felder.datumMin,
`mit der Frist der Vorlage als Vorgabe (${felder.datumWert}, frühestens `
+ `${felder.datumMin})`);
/* EIN CREATOR SIEHT DEN HAKEN NICHT. „Dauerhaft" darf nur, wer
verteilen darf -- und ein Knopf, der eine Absage holt, ist
schlimmer als keiner. Der Server lehnt es ohnehin ab (oben
gemessen); hier wird geprueft, dass er gar nicht erst angeboten
wird. */
ok(felder.haken === false,
`und ein Creator bekommt den „dauerhaft\"-Haken NICHT (${felder.haken})`);
const in9 = await seite.evaluate(() => {
const d = new Date(Date.now() + 9 * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
});
await seite.fill("dialog.nachfrage #nachfrage-datum", in9);
await seite.fill("dialog.nachfrage #nachfrage-grund", "Erst nach dem Umzug anfangen.");
const vorAn = await zaehle();
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(2500);
const nachAn = await zaehle();
ok(nachAn === vorAn + 1,
`„Anpassen" legt genauso eine Aufgabe an (${vorAn} → ${nachAn})`);
/* UND JETZT DIE EIGENTLICHE FRAGE: Steht die Frist dran, die sie
eingetragen hat -- und die Anmerkung? Gefragt wird die
Schnittstelle, nicht die Karte: Was auf dem Brett steht, ist
eine Anzeige; was in der Aufgabe steht, ist die Tatsache. */
const alleA = (await (await ruf("/workspace/api/aufgaben", kTili)).json()).aufgaben || [];
const frisch = alleA.filter((x) => x.frist === in9);
ok(frisch.length === 1,
`und sie trägt die eingetragene Frist (${frisch.length} Aufgabe mit ${in9})`);
ok(frisch[0]?.notizen === 1,
` samt der Anmerkung als Notiz (${frisch[0]?.notizen})`);
const nz2 = await (await ruf(`/workspace/api/aufgaben/${frisch[0]?.id}/notizen`, kTili)).json();
ok(/nach dem Umzug/.test((nz2.notizen || [])[0]?.text || ""),
` mit ihrem Wortlaut („${(nz2.notizen || [])[0]?.text}")`);
/* GEGENPROBE: Abbrechen legt NICHTS an. Ohne sie koennte das
Fenster beim Wegklicken trotzdem verteilen -- und davon merkt
man erst etwas, wenn die Aufgabe irgendwo auftaucht. */
await anpassen.click();
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 8000 });
const vorAb = await zaehle();
await seite.click('dialog.nachfrage [data-teil="nein"]');
await seite.waitForTimeout(1200);
ok(await zaehle() === vorAb,
`Gegenprobe: Abbrechen legt nichts an (${vorAb} → ${await zaehle()})`);
}
/* Und der Zustand des Blocks überlebt ein Neuladen -- wer Vorlagen /* Und der Zustand des Blocks überlebt ein Neuladen -- wer Vorlagen
holt, holt meistens mehrere. */ holt, holt meistens mehrere. */
await seite.reload({ waitUntil: "domcontentloaded" }); await seite.reload({ waitUntil: "domcontentloaded" });
+229 -9
View File
@@ -848,8 +848,223 @@ melde("=== 6. Am echten Bildschirm ===");
await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" }); await kasten2.screenshot({ path: "server/pruef-bewerbung-antwort.png" });
console.log(" -- Bild: server/pruef-bewerbung-antwort.png"); console.log(" -- Bild: server/pruef-bewerbung-antwort.png");
} }
/* ==== „VERSTANDEN" RAEUMT AUF (03.10.2026) ==============
VanVan im Support, Meldung #15: „Die Modis können die
Antworten auf die Bewerbungen im Bereich eure Aufgaben noch
nicht einklappen. Das wird mit der Zeit unübersichtlich."
GEMESSEN WIRD DER GANZE WEG, nicht nur die Route: Knopf
druecken, Zeile rutscht in die zugeklappte Gruppe, Gruppe
laesst sich oeffnen, Text ist noch da -- und nach dem
Neuladen ist sie immer noch gelesen. Der letzte Punkt ist
der eigentliche: Haette ich es im Browserspeicher gemerkt,
waere dieselbe Antwort auf dem naechsten Geraet wieder neu. */
const stand = () => m5.s.evaluate(() => {
const neuB = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
neuZeilen: neuB ? neuB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
altKopf: altB
? (altB.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "").trim()
: null,
altZeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: !!document.querySelector(".vorlagenbrett__antwort-weg"),
};
});
const vorher = await stand();
ok(vorher.neuZeilen >= 1 && vorher.knopf === true,
`die ungelesene Antwort steht offen da, mit „Verstanden" (${vorher.neuZeilen})`);
/* ==== HOECHSTENS DREI AUF EINMAL (03.10.2026) =============
GEMESSEN AM ECHTEN BESTAND, nicht geschaetzt: Ein Modi im
Haus hat NEUN beantwortete Bewerbungen liegen, alle
ungelesen. Auf dem Handy war das Band damit 922 px hoch --
bei 844 px Fenster, also hoeher als der ganze Bildschirm
und 49 % der Seite. Das ist woertlich VanVans Satz: „Das
wird mit der Zeit unuebersichtlich."
Mit dem Deckel sind es 566 px, und ein Griff auf „Alle
verstanden" macht daraus 46 px.
DIE ZAHL DREI GEHOERT GEPRUEFT, nicht nur in den
Quelltext. Deshalb bekommt Frida hier vier weitere
beantwortete Bewerbungen -- erst ab vier greift der
Deckel, und ohne ihn waere diese Pruefung gruen, ohne
etwas gesehen zu haben. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
const nun = new Date().toISOString();
for (let i = 0; i < 4; i++) {
dx.prepare(`INSERT INTO vorlagen_bewerbungen
(vorlage, kategorie, person_id, text, zustand, entscheid_text,
entschieden_von, entschieden_am, erstellt)
VALUES (?,?,?,?,?,?,?,?,?)`)
.run(`deckel-probe-${i}`, "chat", idModi, "Die auch gern.",
"angenommen", null, idHand, nun, nun);
}
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const gedeckelt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
hoch: band ? Math.round(band.getBoundingClientRect().height) : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
fenster: window.innerHeight,
};
});
ok(gedeckelt.zeilen === 3,
`mit fuenf Ungelesenen stehen drei offen (${gedeckelt.zeilen})`);
ok(/und \d+ weitere/.test(gedeckelt.knopf),
` und der Rest ist einen Griff entfernt („${gedeckelt.knopf}")`);
/* DER EIGENTLICHE PUNKT IST DIE HOEHE. „Drei Zeilen" waere
eine Zahl; dass das Band unter den Bildschirm passt, ist
die Aussage. */
ok(gedeckelt.hoch > 0 && gedeckelt.hoch < gedeckelt.fenster,
` das Band passt in den Bildschirm (${gedeckelt.hoch} px bei `
+ `${gedeckelt.fenster} px)`);
/* AUFKLAPPEN ZEIGT ALLE -- wer nachsehen will, kann. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
const aufgeklappt = await m5.s.evaluate(() => {
const band = document.querySelector('.vorlagenbrett__antwort:not([data-alt])');
return {
zeilen: band ? band.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
knopf: band
? (band.querySelector(".vorlagenbrett__antwort-kopf")?.innerText || "")
.replace(/\s+/g, " ").trim()
: "",
};
});
ok(aufgeklappt.zeilen > 3 && /Weniger/.test(aufgeklappt.knopf),
`aufgeklappt stehen alle da (${aufgeklappt.zeilen}, „${aufgeklappt.knopf}")`);
/* UND WIEDER ZU -- der Knopf geht in beide Richtungen. Ohne
diese Zeile koennte er ein Einwegschalter sein. */
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(900);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 3,
"und wieder zu — der Knopf geht in beide Richtungen");
/* DIE VIER PROBEZEILEN WIEDER WEG. Die Zeilen weiter unten
zaehlen „1 aeltere Antwort" und „2 aeltere Antworten" --
mit den Proben waeren es sechs, und sie wuerden rot, ohne
dass etwas kaputt ist. Eine Pruefung, die den Stand fuer
die naechste verschiebt, ist schlimmer als keine. */
{
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
dx.prepare("DELETE FROM vorlagen_bewerbungen WHERE vorlage LIKE 'deckel-probe-%'")
.run();
dx.close();
}
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
ok(await m5.s.evaluate(() => document.querySelectorAll(
'.vorlagenbrett__antwort:not([data-alt]) .vorlagenbrett__antwort-zeile').length) === 2,
"und die Probezeilen sind wieder weg — der Stand ist wie vorher");
ok(vorher.altKopf === null,
`und es gibt noch keine aeltere Gruppe (${vorher.altKopf})`);
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
const danach = await stand();
/* EINE WENIGER, NICHT KEINE. Mein erster Anlauf erwartete hier
null -- Frida hat aber ZWEI Antworten (eine Zusage von
weiter oben und diese Absage), und „Verstanden" gilt je
Zeile. Die Pruefung hatte recht, die Erwartung war falsch;
dass der Knopf nur seine eigene Zeile nimmt, ist genau das
gewollte Verhalten und steht jetzt hier als Aussage. */
ok(danach.neuZeilen === vorher.neuZeilen - 1,
`nach „Verstanden" ist genau diese eine Zeile weg `
+ `(${vorher.neuZeilen} → ${danach.neuZeilen})`);
/* SIE IST NICHT WEG, SIE IST NUR ZU. Eine Absage samt
Begruendung zu loeschen, weil jemand sie einmal gelesen
hat, waere das Gegenteil von dem, was am 30.09. gebaut
wurde. */
ok(/1 ältere Antwort/.test(danach.altKopf || ""),
`sondern hinter einer zugeklappten Zeile („${danach.altKopf}")`);
ok(danach.altZeilen === 0,
` und zugeklappt wird sie gar nicht erst gebaut (${danach.altZeilen})`);
await m5.s.click(".vorlagenbrett__antwort-kopf");
await m5.s.waitForTimeout(800);
const auf = await m5.s.evaluate(() => {
const altB = document.querySelector('.vorlagenbrett__antwort[data-alt="ja"]');
return {
zeilen: altB ? altB.querySelectorAll(".vorlagenbrett__antwort-zeile").length : 0,
text: altB ? (altB.innerText || "").replace(/\s+/g, " ").trim() : "",
};
});
ok(auf.zeilen === 1, `aufgeklappt steht sie wieder da (${auf.zeilen})`);
ok(/schon drei offene/.test(auf.text),
"mitsamt der Begruendung — nachlesen geht weiter");
/* ---- UND DER KNOPF FUER ALLE AUF EINMAL ----------------
Wer nach dem Urlaub sieben Antworten vorfindet, soll nicht
siebenmal tippen. Er steht erst ab zwei da -- bei einer
waere er dasselbe wie der daneben, nur laenger. */
await m5.s.click(".vorlagenbrett__antwort-kopf"); /* wieder zu */
await m5.s.waitForTimeout(500);
const vorAlle = await m5.s.evaluate(() =>
!!document.querySelector(".vorlagenbrett__antwort-alle"));
if (vorAlle) {
await m5.s.click(".vorlagenbrett__antwort-alle");
await m5.s.waitForTimeout(1200);
} else {
/* Nur noch eine offen -- dann nimmt sie ihr eigener Knopf. */
await m5.s.click(".vorlagenbrett__antwort-weg");
await m5.s.waitForTimeout(1200);
}
const leer = await stand();
ok(leer.neuZeilen === 0,
`danach steht oben gar nichts mehr (${leer.neuZeilen})`);
ok(/2 ältere Antworten/.test(leer.altKopf || ""),
`und beide stehen in der zugeklappten Gruppe („${leer.altKopf}")`);
/* UND DER SERVER HAT ES SICH GEMERKT. */
await m5.s.reload({ waitUntil: "networkidle" });
await m5.s.waitForTimeout(1800);
const nachLaden = await stand();
ok(nachLaden.neuZeilen === 0 && /2 ältere Antworten/.test(nachLaden.altKopf || ""),
`nach dem Neuladen gelten sie weiter als gelesen („${nachLaden.altKopf}")`);
/* UND SIE IST WIEDER ZU. Das Aufklappen ist eine Frage des
Augenblicks, kein Zustand, den man mitschleppt. */
ok(nachLaden.altZeilen === 0,
`und wieder zugeklappt (${nachLaden.altZeilen} Zeilen)`);
} finally { await m5.ctx.close(); } } finally { await m5.ctx.close(); }
/* GEGENPROBE AM SERVER: Eine fremde Antwort laesst sich nicht
als gelesen melden -- und eine, auf die noch niemand
geantwortet hat, auch nicht. Ohne diese beiden waere
„gesehen" ein Knopf, mit dem man in fremden Zeilen schreibt. */
{
const fremd = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: hand.keks, body: { id: bw2?.id } });
ok(fremd.code === 404,
`eine fremde Antwort kann niemand als gelesen melden (${fremd.code})`);
const erfunden = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: 999999 } });
ok(erfunden.code === 404, `eine erfundene Nummer auch nicht (${erfunden.code})`);
/* UND ZWEIMAL „VERSTANDEN" AENDERT NICHTS MEHR. */
const nochmal = await roh("/workspace/api/vorlagen/bewerbung/gesehen", {
methode: "POST", keks: modi.keks, body: { id: bw2?.id } });
ok(nochmal.code === 200 && nochmal.json?.wieviele === 0,
`ein zweites „Verstanden" zaehlt nicht noch einmal (${nochmal.json?.wieviele})`);
}
const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280); const h2 = await seiteAls("hand", "CODE-HAND-0001", 1280);
try { try {
await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" }); await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
@@ -1017,17 +1232,22 @@ melde("=== 6. Am echten Bildschirm ===");
/* DER KERN DER MELDUNG. */ /* DER KERN DER MELDUNG. */
await roh(`/workspace/api/aufgaben/${dId}/annehmen`, await roh(`/workspace/api/aufgaben/${dId}/annehmen`,
{ methode: "POST", keks: modi.keks }); { methode: "POST", keks: modi.keks });
const fertig = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, { /* UEBER DEN STATUS, nicht ueber `/mein-stand` (03.10.2026).
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } }); Den zweiten Weg gibt es nicht mehr: Er setzte den
Zuteilungszustand, ohne den Aufgabenstatus mitzuziehen --
VanVans Meldung #8. Geprueft wird jetzt die Tuer, die es
noch gibt. */
const fertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(fertig.code === 409 && fertig.json?.fehler === "dauerhafte_aufgabe", ok(fertig.code === 409 && fertig.json?.fehler === "dauerhafte_aufgabe",
`Frida kann sie nicht abhaken (${fertig.code}/${fertig.json?.fehler})`); `Frida kann sie nicht abhaken (${fertig.code}/${fertig.json?.fehler})`);
/* GEGENPROBE 1: „Ich fange an" geht weiterhin. Auch eine /* GEGENPROBE 1: „Ich fange an" geht weiterhin. Auch eine
stehende Aufgabe hat einen Anfang -- gesperrt ist nur das stehende Aufgabe hat einen Anfang -- gesperrt ist nur das
Abhaken, nicht die Arbeit. */ Abhaken, nicht die Arbeit. */
const anfang = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, { const anfang = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "POST", keks: modi.keks, body: { zustand: "arbeit" } }); methode: "PATCH", keks: modi.keks, body: { status: "arbeit" } });
ok(anfang.code === 200, `„Ich fange an" geht trotzdem (${anfang.code})`); ok(anfang.code === 200, `anfangen geht trotzdem (${anfang.code})`);
/* GEGENPROBE 2: Bei einer GEWOEHNLICHEN Aufgabe geht das Abhaken. /* GEGENPROBE 2: Bei einer GEWOEHNLICHEN Aufgabe geht das Abhaken.
Ohne sie hiesse „409" womoeglich nur, dass niemand je etwas Ohne sie hiesse „409" womoeglich nur, dass niemand je etwas
@@ -1039,8 +1259,8 @@ melde("=== 6. Am echten Bildschirm ===");
const nId = normal.json?.aufgabe?.id ?? normal.json?.id; const nId = normal.json?.aufgabe?.id ?? normal.json?.id;
await roh(`/workspace/api/aufgaben/${nId}/annehmen`, await roh(`/workspace/api/aufgaben/${nId}/annehmen`,
{ methode: "POST", keks: modi.keks }); { methode: "POST", keks: modi.keks });
const ok2 = await roh(`/workspace/api/aufgaben/${nId}/mein-stand`, { const ok2 = await roh(`/workspace/api/aufgaben/${nId}`, {
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } }); methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(ok2.code === 200, ok(ok2.code === 200,
`eine gewoehnliche Aufgabe haekt sie sehr wohl ab (${ok2.code})`); `eine gewoehnliche Aufgabe haekt sie sehr wohl ab (${ok2.code})`);
@@ -1074,8 +1294,8 @@ melde("=== 6. Am echten Bildschirm ===");
const a3 = await lesen(hand.keks); const a3 = await lesen(hand.keks);
ok(beenden.code === 200 && !a3?.dauerhaft, ok(beenden.code === 200 && !a3?.dauerhaft,
`die rechte Hand beendet sie (${beenden.code}, danach ${a3?.dauerhaft})`); `die rechte Hand beendet sie (${beenden.code}, danach ${a3?.dauerhaft})`);
const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, { const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}`, {
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } }); methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
ok(jetztFertig.code === 200, ok(jetztFertig.code === 200,
`und danach laesst sie sich abhaken (${jetztFertig.code})`); `und danach laesst sie sich abhaken (${jetztFertig.code})`);
} }
+351 -1
View File
@@ -105,6 +105,11 @@ const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } }; const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
let fehler = 0, geprueft = 0; let fehler = 0, geprueft = 0;
/* DER DRITTE AUSGANG BRAUCHT EINEN ZAEHLER (03.10.2026). „Konnte
nicht nachsehen" ist weder in Ordnung noch kaputt -- ohne eigene
Zahl verschwaende es zwischen den gruenen Zeilen, und am Ende
stuende „alles in Ordnung" ueber etwas, das niemand gemessen hat. */
let nichtNachsehbar = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */ /* ---- Bestand ------------------------------------------------------------ */
@@ -1063,6 +1068,350 @@ async function tonAufnehmen(seite, ms) {
ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000, ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`); ` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`);
/* ==== DIE ZWEITE FASSUNG, DIE JEDES GERAET ABSPIELT (03.10.2026)
==================================================================
Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst sich
die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt es die
ganze Zeit." Alle sechs Sprachnachrichten im Haus waren WebM.
AUSDRUECKLICH EINE WEBM, und das ist hier der Punkt. Die
Aufnahme ueber den Knopf weiter oben liefert seit dem 02.10.
eine MP4 -- die braucht keine zweite Fassung, und der erste
Anlauf dieser Pruefung mass deshalb NICHTS und wurde rot. Die
Pruefung hatte recht, die Erwartung war falsch: Gemessen werden
muss der Fall, den es bei Miss gibt, und das ist die WebM von
frueher (oder von einem Firefox, der nichts anderes kann).
WAS GEMESSEN WIRD, IST NICHT „die Datei ist da". Das waere ein
gruener Haken ueber etwas, das niemand abspielen kann. Mit
ffprobe wird gefragt, WAS drinsteckt: AAC, in einem MP4, so lang
wie das Original -- und mit der Inhaltsuebersicht VORNE.
DER DRITTE AUSGANG: Ohne ffmpeg gibt es keine zweite Fassung.
Das ist kein Fehler des Programms und wird auch nicht
stillschweigend uebersprungen -- es steht als eigene Zeile da,
mit der Anleitung dazu.
================================================================== */
{
const { ffmpegPfad, ffprobePfad, zweitfassungPfad } =
await import("./helfer-ffmpeg.mjs");
const { execFileSync } = await import("node:child_process");
const { readFileSync: lies, statSync: wiegt } = await import("node:fs");
if (!ffmpegPfad()) {
melde(" -- KONNTE NICHT NACHSEHEN: ffmpeg fehlt auf diesem Rechner.");
melde(" Ohne ffmpeg bekommt keine Sprachnachricht eine zweite");
melde(" Fassung -- die Pruefung kann nicht sagen, ob sie");
melde(" richtig waere. Auf Debian: sudo apt-get install -y ffmpeg");
nichtNachsehbar++;
} else {
/* EINE ECHTE WEBM, vom Browser aufgenommen -- nicht
zusammengebaut. `tonAufnehmen` fragt der Reihe nach und nimmt
das erste Format, das geht; die Liste beginnt mit WebM. */
const webmAuf = await dogi.seite.evaluate(async (ms) => {
const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
spur.getTracks().forEach((t) => t.stop());
return { typ: "", b64: "", bytes: 0 };
}
const r = new MediaRecorder(spur, { mimeType: "audio/webm;codecs=opus" });
const teile = [];
r.ondataavailable = (e) => { if (e.data.size) teile.push(e.data); };
const fertig = new Promise((f) => { r.onstop = f; });
r.start();
await new Promise((f) => setTimeout(f, ms));
r.stop();
await fertig;
spur.getTracks().forEach((t) => t.stop());
const blob = new Blob(teile, { type: "audio/webm" });
const puffer = new Uint8Array(await blob.arrayBuffer());
let roh = "";
for (const b of puffer) roh += String.fromCharCode(b);
return { typ: "audio/webm", b64: btoa(roh), bytes: puffer.length };
}, 2500);
ok(webmAuf.bytes > 500,
`eine echte WebM-Sprachnachricht aufgenommen (${webmAuf.bytes} Bytes)`);
const geschickt = await dogi.seite.evaluate(async ([r, b64]) => {
const roh = atob(b64);
const puffer = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "audio/webm",
"X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "2500" },
body: puffer,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, id: d?.nachricht?.id || 0 };
}, [raumDogi, webmAuf.b64]);
ok(geschickt.status === 201 && geschickt.id > 0,
`sie geht durch (${geschickt.status}, #${geschickt.id})`);
/* AUF DIE UMWANDLUNG WARTEN. Sie laeuft absichtlich NEBEN dem
Hochladen -- die Nachricht soll nicht auf ffmpeg warten.
Deshalb wird hier nachgesehen, statt es anzunehmen, und zwar
mit einer Frist: Ein festes `waitForTimeout` waere entweder
zu kurz (rot ohne Grund) oder zu lang (jeder Lauf zahlt). */
let weg2 = null;
for (let i = 0; i < 40 && !weg2; i++) {
// eslint-disable-next-line no-await-in-loop
weg2 = await dogi.seite.evaluate(async ([r, id]) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const n = (d.nachrichten || []).find((x) => x.id === id);
return n?.anhang?.weg_auch || null;
}, [raumDogi, geschickt.id]);
// eslint-disable-next-line no-await-in-loop
if (!weg2) await new Promise((r) => setTimeout(r, 500));
}
ok(!!weg2, `die zweite Fassung entsteht von selbst (${weg2 || "kam nicht"})`);
ok(/\?form=mp4$/.test(weg2 || ""), ` und sie hat einen eigenen Weg (${weg2})`);
if (weg2) {
/* ---- Was kommt ueber die Leitung? ------------------------- */
const kopf = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w);
const b = new Uint8Array(await a.arrayBuffer());
return {
code: a.status,
typ: a.headers.get("content-type"),
bereiche: a.headers.get("accept-ranges"),
bytes: b.length,
/* Die ersten zwoelf Bytes einer MP4: vier Byte Laenge,
dann „ftyp". Daran erkennt man den Behaelter, ohne ihn
zu glauben. */
marke: String.fromCharCode(...b.slice(4, 8)),
};
}, weg2);
ok(kopf.code === 200 && kopf.typ === "audio/mp4",
`sie kommt als audio/mp4 heraus (${kopf.code}, ${kopf.typ})`);
ok(kopf.marke === "ftyp",
` und ist wirklich ein MP4-Behaelter (Marke „${kopf.marke}")`);
ok(kopf.bereiche === "bytes",
` ueber denselben Ausliefer-Weg wie alles andere (${kopf.bereiche})`);
ok(kopf.bytes > 2000, ` mit Inhalt (${kopf.bytes} Bytes)`);
/* ---- DAS ORIGINAL BLEIBT ABRUFBAR -------------------------
Ohne `?form=mp4` kommt weiter die WebM. Sie ist das, was
hochgeladen wurde; sie zu ersetzen hiesse, die Aufnahme
durch eine Umrechnung davon auszutauschen. */
const original = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w.replace("?form=mp4", ""));
return { code: a.status, typ: a.headers.get("content-type") };
}, weg2);
ok(original.code === 200 && original.typ === "audio/webm",
`das Original bleibt daneben liegen (${original.typ})`);
/* ---- UND WAS STECKT WIRKLICH DRIN? ------------------------ */
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
const tonZeile = db4.prepare(
"SELECT anhang_datei FROM chat_nachrichten WHERE id = ?").get(geschickt.id);
db4.close();
const { DATEN_ORDNER } = await import("./workspace.js");
const quelle = join(DATEN_ORDNER, "chat-anhaenge", tonZeile.anhang_datei);
const ziel = zweitfassungPfad(quelle);
const probe = ffprobePfad();
/* `frag` UND `spawnSyncFuerProbe` STEHEN HIER DRAUSSEN, weil
der Abschnitt „untaugliche Fassung" weiter unten sie auch
braucht. Innerhalb des else-Zweigs waeren sie dort nicht
sichtbar -- und das faellt erst beim Laufen auf. */
const frag = (datei, was) => {
if (!probe) return "";
try {
return execFileSync(probe, ["-v", "error", "-show_entries", was,
"-of", "default=noprint_wrappers=1:nokey=1", datei],
{ encoding: "utf8" }).trim();
} catch { return ""; }
};
const { spawnSync: spawnSyncFuerProbe } = await import("node:child_process");
if (!probe) {
melde(" -- KONNTE NICHT NACHSEHEN: ffprobe fehlt neben ffmpeg.");
nichtNachsehbar++;
} else {
const codec = frag(ziel, "stream=codec_name");
const codecAlt = frag(quelle, "stream=codec_name");
const dauerNeu = Number(frag(ziel, "format=duration"));
const dauerAlt = Number(frag(quelle, "format=duration"));
ok(codecAlt === "opus",
`das Original ist Opus -- also genau der Fall von Miss (${codecAlt})`);
ok(codec === "aac", `drinnen steckt AAC, nicht nur eine neue Endung (${codec})`);
/* ==== DIE LAENGE IST DER TON, NICHT DIE ZEITACHSE ======
Verglichen wird NICHT `format=duration` beider Dateien.
An einer echten Sprachnachricht aus dem Haus gemessen:
Das Original hat 2,76 s Ton auf einer Zeitachse von
63,8 s -- `MediaRecorder` setzt das erste Paket auf 0
und das zweite auf 61,1. Wer die Zeitachsen
vergleicht, prueft dann, ob das Loch mitgewandert ist.
Gefragt wird deshalb nach dem TON: wie viele Bytes
herauskommen, wenn man beide dekodiert. Die muessen
gleich sein -- dann ist nichts abgeschnitten und nichts
dazuerfunden. */
/* UEBER stderr UND NICHT ueber die Rueckgabe: ffmpeg
schreibt seine Zusammenfassung („audio:260KiB") dorthin,
nicht auf die Standardausgabe. Mein erster Anlauf las
die Rueckgabe von `execFileSync` und bekam bei beiden
Dateien 0 KiB -- die Pruefung wurde rot und hatte recht:
Sie hat nichts gemessen. */
const tonBytes = (datei) => {
const r = spawnSyncFuerProbe(ffmpegPfad(),
["-nostdin", "-hide_banner", "-i", datei, "-f", "null", "-"],
{ encoding: "utf8" });
return String(r.stderr || "");
};
const kib = (t) => Number((t.match(/audio:(\d+)KiB/) || [])[1] || 0);
const tonAlt = kib(tonBytes(quelle));
const tonNeu = kib(tonBytes(ziel));
ok(tonAlt > 0 && Math.abs(tonNeu - tonAlt) <= 2,
`der Toninhalt bleibt derselbe (${tonAlt} KiB → ${tonNeu} KiB dekodiert)`);
/* UND DAS LOCH IST WEG. Die Zeitachse der Zweitfassung darf
nicht laenger sein als der Ton, den sie traegt. 48 kHz,
zwei Byte, ein Kanal: 1 KiB sind rund 10,7 ms. */
const tonSekunden = (tonNeu * 1024) / (48000 * 2);
ok(dauerNeu > 0 && dauerNeu < tonSekunden + 1.5,
`und die Zeitachse passt zum Ton (${dauerNeu.toFixed(2)} s fuer `
+ `${tonSekunden.toFixed(2)} s Ton) — das Loch am Anfang ist weg`);
/* GEGENPROBE: Mit dem Loch waere es aufgefallen. Die
Originaldatei aus diesem Lauf hat keines (Chromium
erzeugt es nicht immer) -- deshalb wird hier gerechnet,
was gewesen waere, statt zu behaupten, es koenne nicht
vorkommen. */
ok(dauerAlt === 0 || dauerAlt < tonSekunden + 1.5 || dauerNeu < dauerAlt,
` Gegenprobe: eine Zeitachse von ${dauerAlt.toFixed(2)} s fuer `
+ `${tonSekunden.toFixed(2)} s Ton waere aufgefallen`);
}
/* ---- DIE INHALTSUEBERSICHT STEHT VORNE --------------------
Ohne `+faststart` liegt `moov` am Dateiende. Das
Abspielgeraet muss dann erst bis ans Ende lesen, bevor es
anfangen kann -- genau das „laedt die ganze Zeit", das Miss
in Runde 5 beschrieben hat. Gemessen wird die Reihenfolge
der Kaesten in der Datei, nicht die Zeile im Aufruf. */
const roh = lies(ziel);
const wo = (name) => roh.indexOf(Buffer.from(name, "latin1"));
const moov = wo("moov");
const mdat = wo("mdat");
ok(moov > 0 && mdat > 0 && moov < mdat,
`die Inhaltsuebersicht steht VOR den Tondaten (moov ${moov}, mdat ${mdat})`);
ok(wiegt(ziel).size === kopf.bytes,
` und was auf der Platte liegt, kommt auch heraus (${wiegt(ziel).size} Bytes)`);
/* ---- EINE UNTAUGLICHE FASSUNG WIRD ERSETZT ---------------
Die ersten sechs Zweitfassungen im Haus sind entstanden,
BEVOR das Loch am Anfang bekannt war -- vier davon tragen
es. Mit „gibt es schon, dann weiter" waeren sie fuer immer
so geblieben. Geprueft wird deshalb, dass der Nachruestlauf
eine Fassung, deren Zeitachse nicht zum Ton passt, ERSETZT.
NACHGESTELLT MIT EINER ECHTEN: Dieselbe Aufnahme wird von
Hand mit dem alten Aufruf (ohne `asetpts`) und einem
kuenstlichen Versatz gebaut -- so sieht sie aus wie die
vier auf dem Server. */
const { toeneNachruesten } = await import("./workspace-chat.js");
const { zweitfassungTaugt } = await import("./helfer-ffmpeg.mjs");
/* NICHT `weg2` NENNEN -- so hiess oben schon die Adresse der
Zweitfassung, und `const` im selben Block macht daraus
einen Absturz, BEVOR die obere Zeile ueberhaupt gelesen
wird. Gefunden von der Pruefung selbst. */
const { unlinkSync: dateiLoeschen } = await import("node:fs");
dateiLoeschen(ziel);
/* WIE MAN EINE DATEI MIT LOCH BAUT -- und wie nicht.
`-itsoffset 40` war mein erster Versuch und ergab 2,5 s:
Der MP4-Baukasten rechnet einen reinen Anfangsversatz
wieder heraus. Was bleibt, ist eine Zeitachse, die laenger
ist als der Ton, den sie traegt -- und genau das stellt
eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die
`zweitfassungTaugt` liest, sind dieselben, und darum geht
es hier. */
spawnSyncFuerProbe(ffmpegPfad(), ["-nostdin", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=black:s=32x32:r=1:d=40", "-i", quelle,
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-b:a", "96k",
"-f", "mp4", "-y", ziel]);
const kaputtDauer = Number(frag(ziel, "format=duration"));
ok(kaputtDauer > 30,
`eine Fassung mit zu langer Zeitachse nachgestellt `
+ `(${kaputtDauer.toFixed(1)} s)`);
ok(zweitfassungTaugt(ziel) === false,
" und sie wird als untauglich erkannt");
await toeneNachruesten();
const heilDauer = Number(frag(ziel, "format=duration"));
ok(heilDauer > 0 && heilDauer < 10,
`der Nachruestlauf ersetzt sie (${kaputtDauer.toFixed(1)} s → `
+ `${heilDauer.toFixed(1)} s)`);
/* GEGENPROBE: Die GUTE Fassung laesst er in Ruhe. Ohne sie
koennte der Lauf bei jedem Durchgang alles neu wandeln --
eine Warnung, die immer kommt. */
ok(zweitfassungTaugt(ziel) === true,
" und die neue gilt als tauglich — er wandelt sie nicht ewig weiter");
/* ---- UND BEIM LOESCHEN GEHEN BEIDE ------------------------
Sonst bliebe bei jeder zurueckgenommenen Sprachnachricht
eine m4a liegen -- ohne Zeile, die auf sie zeigt, und damit
fuer immer unzuordenbar. Genau diese Luecke gab es gestern
bei den Supportbildern. */
const weggenommen = await dogi.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
return a.status;
}, geschickt.id);
await new Promise((r) => setTimeout(r, 600));
const nochDa = [quelle, ziel].filter((f) => { try { wiegt(f); return true; } catch { return false; } });
ok(weggenommen < 400 && nochDa.length === 0,
`beim Loeschen gehen BEIDE Dateien (${weggenommen}, ${nochDa.length} liegen noch)`);
}
/* ---- GEGENPROBE: EIN BILD BEKOMMT KEINE ZWEITE FASSUNG -----
Sonst liefe ffmpeg ueber jedes Foto und jedes PDF -- Arbeit
fuer nichts, und eine Tuer, nach der niemand gefragt hat.
Das Bild wird hier selbst geschickt und nicht im Verlauf
gesucht: Mein erster Anlauf suchte eines, fand keines (an
dieser Stelle gab es noch keins) und meldete `undefined`
statt `null` -- gruen waere das nie gewesen, aber es hat
auch nichts geprueft. */
const einBild = await dogi.seite.evaluate(async (r) => {
const png = Uint8Array.from(atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="),
(c) => c.charCodeAt(0));
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "image/png", "X-Dateiname": "punkt.png" },
body: png,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, auch: d?.nachricht?.anhang?.weg_auch };
}, raumDogi);
ok(einBild.status === 201 && einBild.auch === null,
`Gegenprobe: ein Bild bekommt keine zweite Fassung `
+ `(${einBild.status}, ${JSON.stringify(einBild.auch)})`);
/* ==== DIESER BLOCK HINTERLAESST NICHTS (03.10.2026) =========
Die Zeilen weiter unten fragen die Gespraechsliste und
erwarten dort das Wort „Sprachnachricht" -- also das, was
ZULETZT in diesem Raum stand. Mein Probebild liess sie rot
werden, und zwar zu Recht: Nicht die Liste war kaputt,
sondern diese Pruefung hatte den Raum veraendert.
Eine Pruefung, die den Stand fuer die naechste verschiebt,
ist schlimmer als keine: Der Fehler taucht woanders auf, und
dort sucht man ihn dann. Also wird aufgeraeumt. */
await dogi.seite.evaluate(async (r) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const bild = (d.nachrichten || []).filter((n) => n.anhang?.art === "bild").pop();
if (bild) {
await fetch(`/workspace/api/chat/nachrichten/${bild.id}`, { method: "DELETE" });
}
}, raumDogi);
await new Promise((r) => setTimeout(r, 400));
}
}
/* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */ /* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */
const vorschau = await dogi.seite.evaluate(async () => { const vorschau = await dogi.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/raeume"); const a = await fetch("/workspace/api/chat/raeume");
@@ -1453,6 +1802,7 @@ await dogi.ctx.close();
await browser.close(); await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Prüfungen`); melde(`\n${geprueft} Prüfungen`
+ (nichtNachsehbar ? `, ${nichtNachsehbar} konnten nicht nachsehen` : ""));
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0); process.exit(fehler ? 1 : 0);
+155
View File
@@ -368,6 +368,161 @@ melde("=== 2. Gegenproben (jede MUSS anschlagen) ===");
+ "-- die Messung kann also auch nein sagen"); + "-- die Messung kann also auch nein sagen");
} }
/* =====================================================================
DIE STELLE HAELT AUCH, WENN DIE LINIE VERSCHWINDET (05.10.2026)
Miss: "Wenn ich auf den Chat gehen, komme ich zuerst auf die erste
neue Nachricht, aber kurze Zeit spaeter springt er auf die zuletzt
geschrieben Nachricht."
Die Pruefungen oben messen den Zustand nach 2,2 Sekunden -- und der
war immer richtig. Ihr Fehler passiert DANACH: Beim Oeffnen meldet
die Seite "gelesen bis hier". Beim naechsten sanften Nachladen
steht die letzte Nachricht schon in `gelesen_bis`, die Linie
entsteht nicht mehr, und dieselbe Bedingung bedeutete ploetzlich
das Gegenteil -- nicht "das Neue ist jetzt gelesen", sondern
"nichts Neues, also ans Ende".
Deshalb wird hier ZWEIMAL gemessen: einmal wie oben, und einmal
nach weiteren sechs Sekunden, in denen niemand etwas anfasst. Eine
Messung, die nur den ersten Moment ansieht, haette diesen Fehler
nie gefunden -- sie hat ihn wochenlang nicht gefunden.
===================================================================== */
melde("");
melde("=== 4. Ein sanftes Nachladen reisst niemanden ans Ende ===");
{
const person = LEUTE[0];
const keks = await anmelden(person.host, person.rolle, person.code);
const kPartner = await anmelden(person.partnerHost, person.partnerRolle, person.partnerCode);
const gr = await roh(person.host, keks, "/workspace/api/chat/gruppe", "POST",
{ name: `${person.wer} Miss-Fall`, mit: [person.partner] });
const raumId = gr.json().raum_id;
let letzteAlte = 0;
for (let i = 1; i <= 20; i++) {
const a = await roh(person.partnerHost, kPartner,
`/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
{ text: `Alte Nachricht ${i} -- lange genug, damit sie auf einem Handy umbricht.` });
letzteAlte = a.json().nachricht?.id || a.json().id || letzteAlte;
}
await roh(person.host, keks, `/workspace/api/chat/raeume/${raumId}/gelesen`,
"PUT", { bis: letzteAlte });
for (let i = 1; i <= 12; i++) {
await roh(person.partnerHost, kPartner,
`/workspace/api/chat/raeume/${raumId}/nachrichten`, "POST",
{ text: `NEUE Nachricht ${i} -- auch diese ist lang genug zum Umbrechen.` });
}
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 2, isMobile: true, hasTouch: true,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [x, ...r] = k.split("=");
return { name: x, value: r.join("="), domain: person.host, path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 120)));
await seite.goto(`https://${person.host}:${HP}/workspace/chat.html?raum=${raumId}`,
{ waitUntil: "networkidle" });
const messen = () => seite.evaluate(() => {
const v = document.getElementById("verlauf");
if (!v) return { da: false };
const l = v.querySelector(".chat-neu");
return {
da: true,
stelle: Math.round(v.scrollTop),
amEnde: v.scrollHeight - v.scrollTop - v.clientHeight < 40,
linieDa: !!l,
};
});
await seite.waitForTimeout(2200);
const erst = await messen();
ok(erst.da && erst.linieDa && !erst.amEnde,
`beim Oeffnen steht sie an der neuen Stelle (Linie ${erst.linieDa}, am Ende ${erst.amEnde})`);
/* ==== DER FINGERTIPP IST DER AUSLOESER ========================
Der erste Anlauf liess die Seite einfach sechs Sekunden in Ruhe
-- und war mit dem ALTEN Code genauso gruen. Die Messung hat
Miss' Fall gar nicht hergestellt.
Es fehlte das, was ein Mensch beim Lesen zwangslaeufig tut: Er
tippt einmal auf den Verlauf. `wache` nimmt das (zu Recht) als
"er hat die Stelle gesehen" und loescht den Sprung-Merker. Erst
DANACH ist der Weg zu `if (unten)` frei -- und `unten` stand bei
jedem sanften Nachladen wieder auf true.
Getippt wird an den linken Rand, nicht auf eine Nachricht: Es
geht um die Beruehrung, nicht um einen Knopf. */
/* Der Griff muss den Verlauf WIRKLICH treffen -- ein Tipp auf
Koordinaten kann danebengehen, und dann bleibt der Merker
stehen (gemessen: die Ansicht sprang danach zur Linie zurueck,
nicht ans Ende, weil `wache` nie lief). */
await seite.locator("#verlauf").dispatchEvent("touchstart");
await seite.waitForTimeout(400);
/* ==== UND JETZT NACH OBEN LESEN ===============================
Hier wird der Defekt selbst gemessen statt Miss' Ablauf geraten
-- drei Versuche, ihn nachzustellen, waren mit dem ALTEN Code
gruen, und eine Pruefung, die den Fehler nicht herstellt, misst
nichts.
Der Defekt ist eine Zeile: Beim Laden eines Raums wurde `unten`
auf true gesetzt, und zwar auch beim SANFTEN Nachladen. Wer also
irgendwo im Verlauf steht und dessen Strom neu verbindet, wird
ans Ende gerissen -- unabhaengig davon, wie er dorthin kam.
Das ist allgemeiner als Miss' Fall und schliesst ihn ein. */
await seite.evaluate(() => { document.getElementById("verlauf").scrollTop = 500; });
await seite.waitForTimeout(400);
const obenGelesen = await messen();
ok(obenGelesen.stelle <= 600 && !obenGelesen.amEnde,
`sie liest weiter oben (${obenGelesen.stelle} px, am Ende ${obenGelesen.amEnde})`);
/* ==== UND DER ZWEITE AUSLOESER: EIN SANFTER NACHLAUF ===========
Auch mit dem Fingertipp blieb die Messung mit dem alten Code
gruen -- es fehlte das, was `unten` ueberhaupt wieder auf true
setzt: ein erneutes Laden des Raums. Das passiert nicht von
selbst, sondern wenn der Ereignisstrom neu verbindet.
Erst versucht mit einem Verbindungsabriss (offline/online) -- der
loeste in dieser Umgebung keinen aus, und die Messung blieb
gruen, ohne etwas hergestellt zu haben. Jetzt heftet jemand
anderes eine Nachricht an: Das schickt ein `pin`-Ereignis an
alle im Raum, und die Seite laedt sanft nach. Im "Rudel" mit zehn
Leuten ist das Alltag. */
const angeheftet = await roh(person.host, keks,
`/workspace/api/chat/nachrichten/${letzteAlte}/anheften`, "PUT", { an: true });
ok(angeheftet.status === 200,
`jemand heftet etwas an -- das loest den sanften Nachlauf aus (${angeheftet.status})`);
/* Und jetzt sechs Sekunden nichts weiter tun -- genau das, was Miss
beschreibt: Sie liest, und "kurze Zeit spaeter" springt es. */
await seite.waitForTimeout(6000);
const spaeter = await messen();
ok(spaeter.amEnde === false,
`nach dem Nachladen steht sie nicht am Ende (amEnde ${spaeter.amEnde})`);
/* Die eigentliche Zahl: Die Bildlaufposition darf sich nicht
nennenswert bewegt haben. "Nicht am Ende" allein koennte auch
heissen, dass sie irgendwohin anders gesprungen ist. */
ok(Math.abs(spaeter.stelle - obenGelesen.stelle) <= 60,
`und zwar da, wo sie gelesen hat (${obenGelesen.stelle} -> ${spaeter.stelle} px)`);
/* Dass die Linie inzwischen WEG sein darf, ist kein Fehler --
gelesen ist gelesen. Nur springen darf es deswegen nicht. */
melde(` (die Linie ist jetzt ${spaeter.linieDa ? "noch da" : "weg -- gelesen"},`
+ " das ist in Ordnung)");
ok(konsole.length === 0, `keine Abstuerze (${konsole.join(" | ") || "keine"})`);
await kontext.close();
}
await browser.close(); await browser.close();
vorbau.close(); vorbau.close();
+151
View File
@@ -1065,6 +1065,157 @@ melde("\n=== Wer hat reagiert? ===");
" das Vorlesewort der Kachel nennt sie auf beiden Geräten"); " das Vorlesewort der Kachel nennt sie auf beiden Geräten");
} }
/* =====================================================================
BEARBEITEN -- AM ECHTEN BROWSER (03.10.2026)
Filipe: "jeder soll seine eigene nachricht bearbeiten koennen."
Die Route ist in pruef-chat gemessen. Hier geht es um das, was eine
Route nicht zeigen kann: Steht der Knopf da, wo man ihn braucht,
steht er NICHT an fremden Nachrichten, und kommt der neue Text bei
dem an, der daneben sitzt -- ohne dass er neu laedt.
DIE LETZTE FRAGE IST DIE WICHTIGSTE. Ein Bearbeiten, das nur der
Schreibende sieht, ist schlimmer als keins: Zwei Leute haetten
denselben Satz verschieden im Bild und wuessten es nicht.
===================================================================== */
console.log("\n=== Eine eigene Nachricht bearbeiten (im Browser) ===");
{
/* EIGENE SITZUNGEN, NICHT DIE VON OBEN. Die Namen `luna` und `pat`
gibt es hier noch, ihre Kontexte sind aber seit Abschnitt 3
geschlossen -- und Playwright meldet dann nur "Target page has
been closed", nicht, woran es liegt. Genau daran bin ich hier
einmal haengengeblieben. */
const luna2 = await alsRolle("creator", "CODE-CREA-0001");
const pat2 = await alsRolle("scout", "CODE-SCOU-0001");
/* DERSELBE RAUM BEI BEIDEN -- und zwar ueber die NUMMER, nicht ueber
"den ersten in der Liste" (03.10.2026).
Der erste Anlauf liess beide auf `.chat-raum__knopf` klicken, also
jeweils auf ihr oberstes Gespraech. Inzwischen gibt es in diesem
Lauf mehrere: Luna landete in ihrem, Patrick in einem anderen, und
die Messung meldete "Patrick sieht die Nachricht nicht" -- ein
Befund, der dem Testaufbau gehoerte und nicht dem Haus.
Schlimmer war die Nebenwirkung: Die Pruefung "an ihr steht KEIN
bearbeiten" war gruen, weil die Nachricht ueberhaupt nicht da war.
Ein gruener Haken fuer ein Element, das es nicht gibt -- genau die
Sorte, die nichts beweist. Deshalb haengt sie unten jetzt daran,
dass er sie wirklich sieht. */
await luna2.seite.waitForSelector(".chat-raum__knopf", { timeout: 10000 });
const raumNr = await luna2.seite.evaluate(() => {
const li = [...document.querySelectorAll("li[data-id]")]
.find((x) => /Patrick/.test(x.textContent));
return li ? li.dataset.id : null;
});
ok(!!raumNr, `das Gespraech mit Patrick steht in Lunas Liste (#${raumNr})`);
for (const wer of [luna2, pat2]) {
await wer.seite.waitForSelector(`li[data-id="${raumNr}"] .chat-raum__knopf`, { timeout: 10000 });
await wer.seite.click(`li[data-id="${raumNr}"] .chat-raum__knopf`);
await wer.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 10000 });
}
await luna2.seite.waitForTimeout(600);
await luna2.seite.fill("#text", "Treffen um 15 Urh");
await luna2.seite.click("#senden");
await luna2.seite.waitForTimeout(900);
/* Bei Luna steht der Knopf, bei Patrick an derselben Nachricht
nicht -- das ist die Gegenprobe, und sie laeuft am SELBEN Satz. */
const knopfBeiLuna = await luna2.seite.evaluate(() => {
const k = [...document.querySelectorAll(".chat-nachricht")]
.find((x) => /15 Urh/.test(x.textContent));
return !!k?.querySelector(".chat-nachricht__bearb-knopf");
});
ok(knopfBeiLuna, "an der eigenen Nachricht steht „bearbeiten“");
await pat2.seite.waitForTimeout(600);
const knopfBeiPat = await pat2.seite.evaluate(() => {
const k = [...document.querySelectorAll(".chat-nachricht")]
.find((x) => /15 Urh/.test(x.textContent));
return { da: !!k, knopf: !!k?.querySelector(".chat-nachricht__bearb-knopf") };
});
/* DIE ZWEITE ZEILE HAENGT AN DER ERSTEN. "Kein Knopf" ist sonst auch
wahr, wenn die Nachricht gar nicht da ist -- und dann prueft die
Zeile nichts. */
ok(knopfBeiPat.da, "Patrick sieht dieselbe Nachricht");
ok(knopfBeiPat.da && knopfBeiPat.knopf === false,
"und an ihr KEIN „bearbeiten“ — fremde Worte aendert niemand");
/* Luna oeffnet das Feld und richtet den Tippfehler. */
await luna2.seite.evaluate(() => {
[...document.querySelectorAll(".chat-nachricht")]
.find((x) => /15 Urh/.test(x.textContent))
?.querySelector(".chat-nachricht__bearb-knopf")?.click();
});
await luna2.seite.waitForSelector(".chat-bearb__feld", { timeout: 8000 });
const feldGross = await luna2.seite.evaluate(() => {
const f = document.querySelector(".chat-bearb__feld");
const r = f.getBoundingClientRect();
return { schrift: parseFloat(getComputedStyle(f).fontSize), hoch: Math.round(r.height),
wert: f.value };
});
ok(feldGross.wert === "Treffen um 15 Urh",
`im Feld steht der alte Text ("${feldGross.wert}")`);
/* 16 px, sonst zoomt iOS beim Hineintippen die ganze Seite heran. */
ok(feldGross.schrift >= 16, `Schrift im Feld ${feldGross.schrift} px (mindestens 16)`);
await luna2.seite.fill(".chat-bearb__feld", "Treffen um 15 Uhr");
await luna2.seite.click(".chat-bearb__knopf--ja");
await luna2.seite.waitForTimeout(900);
const beiLunaNach = await luna2.seite.evaluate(() => {
const k = [...document.querySelectorAll(".chat-nachricht")]
.find((x) => /15 Uhr/.test(x.textContent));
return {
text: k?.querySelector(".chat-nachricht__text")?.textContent,
vermerk: k?.querySelector(".chat-nachricht__bearbeitet")?.textContent || null,
feldNochDa: !!document.querySelector(".chat-bearb__feld"),
};
});
ok(beiLunaNach.text === "Treffen um 15 Uhr",
`bei ihr steht der neue Text ("${beiLunaNach.text}")`);
ok(beiLunaNach.vermerk === "bearbeitet",
`und daneben der Vermerk (${beiLunaNach.vermerk})`);
ok(beiLunaNach.feldNochDa === false, "das Feld ist wieder zu");
/* DIE VORSCHAU LINKS MUSS MITKOMMEN (03.10.2026).
Diese Zeile gibt es, weil ein Bildschirmfoto sie erzwungen hat:
Rechts stand schon "Treffen um 15 Uhr", links in der
Gespraechsliste weiter "Du: Treffen um 15 Urh". Derselbe Satz,
zweimal verschieden, auf einem Schirm -- und keine einzige
Messung hat es gemerkt, weil beide Haelften fuer sich stimmten. */
await luna2.seite.waitForTimeout(700);
const vorschau = await luna2.seite.evaluate((nr) =>
document.querySelector(`li[data-id="${nr}"]`)?.textContent || "", raumNr);
ok(/15 Uhr/.test(vorschau) && !/15 Urh/.test(vorschau),
`die Vorschau in der Liste zeigt den neuen Text ("${vorschau.replace(/\s+/g, " ").trim()}")`);
/* UND BEI PATRICK -- ohne Neuladen, ueber den Ereignisstrom. */
await pat2.seite.waitForTimeout(1200);
const beiPatNach = await pat2.seite.evaluate(() => {
const alle = [...document.querySelectorAll(".chat-nachricht")];
const k = alle.find((x) => /15 Uhr/.test(x.textContent));
return {
neu: k?.querySelector(".chat-nachricht__text")?.textContent,
vermerk: !!k?.querySelector(".chat-nachricht__bearbeitet"),
altNochDa: alle.some((x) => /15 Urh/.test(x.textContent)),
};
});
ok(beiPatNach.neu === "Treffen um 15 Uhr",
`bei Patrick steht er auch -- ohne Neuladen ("${beiPatNach.neu}")`);
ok(beiPatNach.vermerk, "mit demselben Vermerk");
ok(beiPatNach.altNochDa === false,
"und der alte Satz ist nicht doppelt stehengeblieben");
await luna2.seite.screenshot({ path: new URL("./pruef-chat-bearbeiten.png", import.meta.url)
.pathname.replace(/^\/([A-Za-z]:)/, "$1") });
await luna2.ctx.close();
await pat2.ctx.close();
}
await browser.close(); await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+115
View File
@@ -313,6 +313,121 @@ console.log("\n=== Ungelesene Nachrichten ===");
ok(zurueck === 0, `ein alter "gelesen bis 1" dreht nichts zurück (${zurueck})`); ok(zurueck === 0, `ein alter "gelesen bis 1" dreht nichts zurück (${zurueck})`);
} }
/* =======================================================================
6b. EIGENE NACHRICHT BEARBEITEN (03.10.2026)
Filipe: "die nachrichten die man in den chat reinschreibt. jeder
soll seine eigene nachricht bearbeiten koennen."
DIE WICHTIGSTE ZEILE IST DIE GEGENPROBE MIT DOGFATHER. Er darf
JEDE Nachricht loeschen -- fuer den Notfall, so steht es seit dem
22.09. im Haus. Beim Bearbeiten darf er das ausdruecklich NICHT:
Eine fremde Nachricht zu entfernen heisst "das soll hier nicht
stehen"; sie zu AENDERN heisst, jemandem Worte in den Mund zu
legen, die unter seinem Namen und seinem Bild stehen bleiben.
Waere der Loeschweg hier blind mituebernommen worden, haette es
niemandem auffallen muessen -- es sieht ja aus wie dieselbe
Befugnis.
======================================================================= */
console.log("\n=== Eine eigene Nachricht bearbeiten ===");
{
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Ich kome moregn" }, "POST");
const { nachricht } = await a.json();
ok(!!nachricht?.id, `Luna schreibt etwas mit Tippfehlern (#${nachricht?.id})`);
/* Vorher merken: Rueckt der Raum beim Bearbeiten nach oben? Er
darf NICHT -- eine Tippfehlerkorrektur ist keine Wortmeldung. */
const vorher = await holen(luna.keks, "/workspace/api/chat/raeume");
const raumVorher = (vorher.raeume || []).find((r) => r.id === raumLunaPat);
ok(!!raumVorher?.letzte?.erstellt,
`der Raum steht in der Liste und hat einen Zeitpunkt (${raumVorher?.letzte?.erstellt || "keinen"})`);
const standVorher = raumVorher?.letzte?.erstellt;
/* ---- Die eigene: geht ------------------------------------------- */
const b = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Ich komme morgen" }, "PATCH");
ok(b.ok, `die eigene bearbeiten: geht (${b.status})`);
const bd = await b.json().catch(() => ({}));
ok(!!bd.bearbeitet_am, `und der Zeitpunkt kommt zurueck (${bd.bearbeitet_am || "nichts"})`);
/* ---- Steht es auch im Verlauf? ---------------------------------- */
const verlauf = await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const drin = (verlauf.nachrichten || []).find((x) => x.id === nachricht.id);
ok(drin?.text === "Ich komme morgen",
`der neue Text steht im Verlauf ("${drin?.text}")`);
ok(!!drin?.bearbeitet_am,
`und der Vermerk "bearbeitet" geht mit nach draussen (${drin?.bearbeitet_am || "fehlt"})`);
/* DIE SPALTE MUSS AUCH IM SELECT STEHEN. Genau das hat am 03.10.
schon einmal einen halben Tag gekostet: `anhang_datei` fehlte dort,
die Abbildung lieferte brav `null`, und alles sah gebaut aus. */
/* ---- Der Raum ist NICHT nach oben gerueckt ---------------------- */
const nachher = await holen(luna.keks, "/workspace/api/chat/raeume");
const raumNachher = (nachher.raeume || []).find((r) => r.id === raumLunaPat);
ok(!!standVorher && standVorher === raumNachher?.letzte?.erstellt,
`der Raum rueckt nicht nach oben (${standVorher} -> ${raumNachher?.letzte?.erstellt})`);
/* Die Vorschau in der Liste zieht aber sehr wohl nach -- sie liest
dieselbe Nachricht. Stuende dort weiter der Tippfehler, waere die
Liste eine zweite, veraltete Wahrheit neben dem Verlauf. */
ok(raumNachher?.letzte?.text === "Ich komme morgen",
`aber die Vorschau zeigt den neuen Text ("${raumNachher?.letzte?.text}")`);
/* ---- GEGENPROBE: fremde Nachricht ------------------------------- */
const c = await rufe(pat.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von Patrick umgeschrieben" }, "PATCH");
ok(c.status === 403, `Gegenprobe: Patrick aendert sie NICHT (${c.status})`);
/* ---- GEGENPROBE, DIE ZAEHLT: auch DogFather nicht --------------- */
const d = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von DogFather umgeschrieben" }, "PATCH");
ok(d.status === 403 || d.status === 404,
`auch DogFather nicht -- loeschen darf er, umschreiben nicht (${d.status})`);
const nochda = await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const unberuehrt = (nochda.nachrichten || []).find((x) => x.id === nachricht.id);
ok(unberuehrt?.text === "Ich komme morgen",
`und ihr Text ist unberuehrt ("${unberuehrt?.text}")`);
/* ---- Leer und zu lang ------------------------------------------- */
const leer = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: " " }, "PATCH");
ok(leer.status === 400, `leer geht nicht -- dafuer gibt es loeschen (${leer.status})`);
const lang = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "x".repeat(4001) }, "PATCH");
ok(lang.status === 400, `zu lang geht nicht (${lang.status})`);
/* ---- Unveraendert setzt KEINEN Vermerk -------------------------- */
const zweite = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Steht schon richtig" }, "POST");
const n2 = (await zweite.json()).nachricht;
const gleich = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`,
{ text: "Steht schon richtig" }, "PATCH");
const gd = await gleich.json().catch(() => ({}));
ok(gleich.ok && gd.unveraendert === true && !gd.bearbeitet_am,
`derselbe Text setzt keinen Vermerk (${JSON.stringify(gd)})`);
const pruefN2 = (await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`)).nachrichten
.find((x) => x.id === n2.id);
ok(!pruefN2?.bearbeitet_am,
`und an ihr steht nichts -- sonst waere der Vermerk nichts mehr wert`);
/* ---- Eine geloeschte laesst sich nicht bearbeiten --------------- */
await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`, undefined, "DELETE");
const tot = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`,
{ text: "Doch noch etwas" }, "PATCH");
ok(tot.status === 404,
`eine geloeschte Nachricht gibt es nicht mehr zu bearbeiten (${tot.status})`);
/* ---- Wer nicht im Raum ist, sieht sie gar nicht ----------------- */
const fremderRaum = await rufe(nora.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von Nora" }, "PATCH");
ok(fremderRaum.status === 404,
`wer nicht im Raum ist, bekommt 404 und nicht 403 (${fremderRaum.status})`);
}
/* ======================================================================= /* =======================================================================
7. Zurücknehmen 7. Zurücknehmen
======================================================================= */ ======================================================================= */
+350
View File
@@ -0,0 +1,350 @@
/* =====================================================================
EIN DIALOG MUSS IM BILD STEHEN (05.10.2026)
=====================================================================
Patrick über den Support: „Sophie kann Abschnitt 3 und 4 nicht
bestätigen, beim drücken kommt ein schwarzer Bildschirm."
Der Dialog war die ganze Zeit da — nur nicht zu sehen. `module.css`
setzte `.dialog { position: relative }`, und damit war die
Zentrierung des Browsers überschrieben: Ein Dialog aus
`showModal()` gehört mit `position: fixed` in die Mitte des
SICHTBAREN FENSTERS, mit `relative` landet er im Dokumentfluss nahe
dem SEITENANFANG. Wer nach unten gescrollt hatte, sah nur noch den
abdunkelnden Schleier.
---------------------------------------------------------------------
WARUM ES NIEMANDEM AUFGEFALLEN IST
Auf einer kurzen Seite scrollt niemand, und dann stimmt es zufällig.
Es traf 22 Aufrufe von `showModal()` in 10 Dateien — vom Löschen
einer Datei bis zum Eintragen eines Monatsziels. Keine einzige
Prüfung im Haus hat je einen Dialog geöffnet, NACHDEM sie gescrollt
hat. Genau das tut diese hier.
---------------------------------------------------------------------
DREI EBENEN, UND DIE UNTERSTE ALLEIN WÄRE ZU WENIG
1. Im Quelltext: Keine Regel darf `.dialog` wieder aus dem Fenster
nehmen. Das findet den Rückfall sofort und ohne Browser.
2. Am echten Bildschirm: zwei verschiedene Dialoge auf zwei
verschiedenen Seiten, jeweils ganz nach unten gescrollt.
3. Die Gegenprobe: Mit wieder eingesetztem `position: relative`
MUSS die Messung anschlagen. Ohne sie wüsste niemand, ob hier
überhaupt etwas gemessen wird — „ist im Bild" ist auf einer
ungescrollten Seite immer wahr.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { request as httpAnfrage } from "node:http";
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Dialogpruefung");
const HIER = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "ws-dlg-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
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";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) {
return ec.call(this, n, w, { ...(o || {}), secure: false });
};
await import("./index.js");
notbremse(240_000, "pruef-dialog-sichtbar");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function braucht(bedingung, was) {
if (bedingung) return;
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
/* =====================================================================
1 · IM QUELLTEXT
=====================================================================
Billig, schnell und ohne Browser -- und findet den Rueckfall, bevor
jemand ihn sieht. Gesucht wird in ALLEN Stilvorlagen, nicht nur in
module.css: Die naechste Regel koennte woanders stehen.
===================================================================== */
melde("\n=== Im Quelltext ===");
const CSS_ORDNER = join(HIER, "..", "workspace", "assets", "css");
const dateien = readdirSync(CSS_ORDNER).filter((f) => f.endsWith(".css"));
braucht(dateien.length > 5, `nur ${dateien.length} Stilvorlagen gefunden`);
const schlecht = [];
let regeln = 0;
/** Trifft diese Auswahl den Dialog SELBST?
*
* Beim ersten Lauf hat die Suche drei Fehlalarme gemeldet, und alle
* drei waren meine Schuld, nicht die des Hauses:
*
* * ein KOMMENTAR, in dem das Wort .dialog vorkam
* * `.dialog > *` -- das sind die KINDER, die duerfen relativ sein
* * `.dialog__titel` -- ein ganz anderes Element
*
* Eine Suche, die bei jedem Lauf meckert, wird nach dem zweiten Mal
* weggeklickt, zusammen mit dem echten Befund darin. Deshalb zaehlt
* nur, was wirklich auf dem Dialog endet. */
const trifftDialog = (auswahl) => auswahl.split(",").some((teil) => {
const t = teil.trim();
if (!t) return false;
/* Das letzte Glied der Auswahl muss der Dialog sein -- nach einem
Leerzeichen oder `>` kommen die Kinder. */
const letztes = t.split(/[\s>+~]+/).filter(Boolean).pop() || "";
if (!/(^|\.)dialog(?![\w-])/.test(letztes)) return false;
return !/::/.test(letztes); // ::backdrop und Co. duerfen
});
for (const name of dateien) {
/* KOMMENTARE ZUERST WEG. Dieses Haus schreibt viel in Kommentare --
darunter das Wort `.dialog`. Ohne diesen Schritt misst die
Pruefung Prosa. */
const text = readFileSync(join(CSS_ORDNER, name), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const bloecke = text.matchAll(/([^{}]*)\{([^{}]*)\}/g);
for (const b of bloecke) {
const auswahl = b[1].trim();
if (!trifftDialog(auswahl)) continue;
const stellung = b[2].match(/(^|[;\s])position\s*:\s*([a-z-]+)/);
if (!stellung) continue;
regeln++;
if (/^(relative|static|absolute)$/.test(stellung[2])) {
schlecht.push(`${name}: ${auswahl.slice(0, 48)} → position: ${stellung[2]}`);
}
}
}
ok(regeln > 0, `${regeln} Regel(n) setzen die Stellung eines Dialogs — es gibt etwas zu prüfen`);
ok(schlecht.length === 0, schlecht.length
? `${schlecht.length} Regel(n) nehmen den Dialog aus dem Fenster: ${schlecht.join(" · ")}`
: "keine Regel nimmt den Dialog aus dem Fenster");
/* GEGENPROBE fuer die Textsuche: Sie muss so eine Regel auch WIRKLICH
finden. Eine Suche, die nie etwas findet, meldet immer „in Ordnung". */
ok(trifftDialog(".dialog") && trifftDialog(".haus .dialog") && trifftDialog("a, .dialog"),
"Gegenprobe: der Dialog selbst wird erkannt");
ok(!trifftDialog(".dialog > *") && !trifftDialog(".dialog__titel")
&& !trifftDialog(".dialog .mz-feld"),
"Gegenprobe: Kinder und gleichnamige Nachbarn werden NICHT erkannt");
/* =====================================================================
2 · AM ECHTEN BILDSCHIRM
===================================================================== */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const kenn = randomBytes(32).toString("hex");
const jetzt = new Date().toISOString();
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", kenn, jetzt);
const CODES = {};
function anlegen(name, rolle, code) {
const salz = randomBytes(16).toString("hex");
const h = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n,"
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, h, salz, 32768,
createHmac("sha256", kenn).update(code).digest("hex"), "2025-10-01T08:00:00Z");
CODES[name] = code;
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idCreator = anlegen("Creatorin", "creator", "CODE-DLG-CREA-1");
anlegen("Kevin", "scout", "CODE-DLG-SCOUT-1");
/* DIE TABELLEN ENTSTEHEN ERST BEIM ERSTEN ZUGRIFF
(`tabelle()` in workspace-schulung.js). Ohne diesen Aufruf gibt es
`schulungen` noch gar nicht -- beim ersten Lauf stand hier „no such
table". Das ist kein Fehler im Haus, sondern die Reihenfolge. */
d.close();
await anfrageVorab();
const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
/* Vier Unterweisungen -- genau Sophies Lage: die ersten beiden
abgeschlossen, die unteren beiden offen. Dadurch stehen die
Knoepfe weit unten, und genau darum ging es. */
for (let i = 1; i <= 4; i++) {
d2.prepare(`INSERT INTO schulungen (titel, beschreibung, pflicht, aktiv, erstellt)
VALUES (?,?,1,1,?)`).run(`Abschnitt ${i}`, `Regeln, Teil ${i}`, jetzt);
}
for (const sid of [1, 2]) {
d2.prepare(`INSERT INTO schulung_bestaetigt
(schulung_id, creator_id, betreuer_id, creator_am, betreuer_am,
abgeschlossen, abgeschlossen_am) VALUES (?,?,1,?,?,1,?)`)
.run(sid, idCreator, jetzt, jetzt, jetzt);
}
d2.close();
/* Ein Aufruf, der die Schulungstabellen anlegt -- angemeldet, denn
die Schnittstelle will eine Sitzung sehen. */
async function anfrageVorab() {
const an = await anfrage("/workspace/api/anmelden", "POST",
{ rolle: "creator", code: "CODE-DLG-CREA-1" });
braucht(an.code === 200, `Vorab-Anmeldung ergab ${an.code}`);
const k = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
await new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/schulung", method: "GET",
headers: { Cookie: k, Origin: `http://127.0.0.1:${PORT}` },
}, (r) => { r.on("data", () => {}); r.on("end", fertig); });
a.on("error", schief);
a.end();
});
}
/* Als FUNKTION und nicht als `const`: Sie wird von `anfrageVorab`
weiter oben gebraucht, und eine `const` ist dort noch nicht da
(ReferenceError beim ersten Lauf). Eine Funktion wird vorgezogen. */
function anfrage(pfad, art, koerper) { return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: art,
headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` },
}, (r) => {
let t = ""; r.on("data", (x) => { t += x; });
r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
}); }
async function keksFuer(rolle, code) {
const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
braucht(an.code === 200, `Anmeldung ${rolle} ergab ${an.code}`);
return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; ");
}
const keks = {
creator: await keksFuer("creator", "CODE-DLG-CREA-1"),
scout: await keksFuer("scout", "CODE-DLG-SCOUT-1"),
};
const pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
/** Oeffnet einen Dialog NACH dem Scrollen und sagt, wo er landet.
* `kaputtMachen` setzt die alte, fehlerhafte Regel wieder ein --
* damit laesst sich beweisen, dass die Messung auch Nein sagen kann. */
async function dialogMessen(wer, pfad, knopfWahl, breite, hoehe, kaputtMachen = false) {
const kontext = await browser.newContext({
viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860,
});
await kontext.addCookies(keks[wer].split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
await seite.goto(`http://127.0.0.1:${PORT}${pfad}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
if (kaputtMachen) {
/* WAS DIESE GEGENPROBE BEWEIST -- und was nicht.
Sie beweist, dass die MESSUNG anschlagen kann: Ein Dialog, der
nicht im sichtbaren Fenster steht, wird als solcher erkannt.
Ohne sie waere „er steht im Bild" womoeglich eine Zeile, die
immer wahr ist.
Sie baut NICHT die historische Regel nach. Das habe ich zuerst
versucht und bin zweimal danebengelandet -- ein Element in der
obersten Ebene rechnet `relative` zu `absolute` um, und die
Reparatur schreibt `inset: 0` und `margin: auto` so
ausdruecklich hin, dass der Dialog selbst mit verstellter
Stellung noch mittig bleibt. Das ist eine gute Nachricht ueber
die Reparatur und eine schlechte ueber jenen Nachbau.
Also wird hier schlicht ein Dialog aus dem Fenster geschoben.
Die Frage lautet: Merkt die Messung es? */
await seite.addStyleTag({
content: ".dialog { position: absolute !important; inset: auto !important;"
+ " top: -9999px !important; margin: 0 !important; }",
});
}
/* GANZ NACH UNTEN -- das ist der Unterschied zu allem, was im Haus
bisher gemessen wurde. */
await seite.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await seite.waitForTimeout(350);
const gescrollt = await seite.evaluate(() => Math.round(window.scrollY));
const knopf = await seite.$(knopfWahl);
if (!knopf) { await kontext.close(); return { fehltKnopf: true, gescrollt }; }
await knopf.click();
await seite.waitForTimeout(700);
const lage = await seite.evaluate(() => {
const dlg = [...document.querySelectorAll("dialog")].find((x) => x.open);
if (!dlg) return null;
const k = dlg.getBoundingClientRect();
return {
oben: Math.round(k.top), unten: Math.round(k.bottom),
hoehe: Math.round(k.height), fenster: window.innerHeight,
stellung: getComputedStyle(dlg).position,
/* GANZ im Bild, nicht „irgendwie angeschnitten". Ein Dialog,
dessen Knopf unter dem Fensterrand liegt, ist so unbrauchbar
wie einer, den man gar nicht sieht. */
ganzImBild: k.top >= -1 && k.bottom <= window.innerHeight + 1,
};
});
await kontext.close();
return { ...(lage || { fehlt: true }), gescrollt };
}
melde("\n=== Am echten Bildschirm ===");
const FAELLE = [
["der gemeldete Fall: Unterweisung bestätigen", "creator",
"/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein"],
/* NICHT `.mz-zeile__fuss .knopf` -- das ist seit dem Schnell-Eintrag
der Ein-Klick-Knopf, der gar keinen Dialog oeffnet. Beim ersten
Lauf stand hier deshalb „es ging kein Dialog auf", und das war
meine veraltete Auswahl, kein Befund. */
["ein zweiter, ganz anderer Dialog: Monatsziel eintragen", "scout",
"/workspace/manager-ziele.html", '.mz-schnell button:text-is("Mehr Angaben")'],
];
const gemessen = [];
for (const [was, wer, pfad, wahl] of FAELLE) {
for (const [wie, b, h] of [["Computer", 1280, 800], ["Handy", 412, 780]]) {
const r = await dialogMessen(wer, pfad, wahl, b, h);
if (r.fehltKnopf) { ok(false, `${was} (${wie}): der Knopf ist gar nicht da`); continue; }
if (r.fehlt) { ok(false, `${was} (${wie}): es ging kein Dialog auf`); continue; }
gemessen.push(r);
ok(r.gescrollt > 0,
`${was} (${wie}): die Seite liess sich überhaupt scrollen (${r.gescrollt} px)`);
ok(r.stellung === "fixed",
`${was} (${wie}): der Dialog hängt am Fenster (${r.stellung})`);
ok(r.ganzImBild,
`${was} (${wie}): er steht GANZ im Bild (oben ${r.oben}, unten ${r.unten} von ${r.fenster})`);
}
}
braucht(gemessen.length === 4, `nur ${gemessen.length} von 4 Fällen gemessen`);
/* =====================================================================
3 · DIE GEGENPROBE
=====================================================================
Mit der alten Regel MUSS dieselbe Messung anschlagen. Sonst hiesse
„er steht im Bild" moeglicherweise nur „diese Zeile ist immer wahr".
===================================================================== */
melde("\n=== Gegenprobe: mit der alten Regel muss es auffallen ===");
const kaputt = await dialogMessen("creator",
"/workspace/bereich.html?b=schutz", ".unterweisung .knopf--klein", 1280, 800, true);
/* NICHT `=== "relative"`: Ein Element in der obersten Ebene rechnet
`relative` zu `absolute` um -- gemessen, nicht nachgelesen. Wichtig
ist nur, dass es nicht mehr am Fenster haengt. */
ok(kaputt.stellung !== "fixed",
`die alte Regel ist wieder eingesetzt (${kaputt.stellung} statt fixed)`);
ok(kaputt.ganzImBild === false,
`und die Messung schlägt an: oben ${kaputt.oben}, Fenster ${kaputt.fenster}`
+ " — ein Dialog ausserhalb des Fensters faellt auf");
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);
+900
View File
@@ -0,0 +1,900 @@
/* =====================================================================
AGENTUR-EVENTS: KARTE, PUNKTE UND FORMULAR (03.10.2026)
Filipe, mit einem Bildschirmfoto des Oktober-Events: „ich will das
viel geiler viel perfekter. und auch wenn man die eintraegt und so
soll viel mehr perfektioniert und uebersichtlicher sein."
Umgebaut wurden drei Dinge, und alle drei werden hier nachgemessen:
1. DIE KARTE Buehne mit Titel auf dem Banner statt 638 px
leerem Bild darueber; Zeitraum einmal statt
dreimal; „noch 13 Tage" als Antwort.
2. DIE PUNKTE Aus Fliesstext wird eine Liste zum Abhaken,
jeder fuer sich. Neue Tabelle event_punkte.
3. DAS FORMULAR Ein Feld je Zeile statt eines leeren Textfeldes,
in das man Aufzaehlungszeichen selbst tippt.
WAS HIER AUSDRUECKLICH NICHT NUR BESTAETIGT WIRD:
Zu jeder Zusage gehoert eine Gegenprobe, die zeigt, dass die
Pruefung auch „nicht in Ordnung" sagen kann -- sonst bestaetigt sie
nichts (Projektgedaechtnis, 01.09.2026: `every()` auf einem leeren
Feld meldet `true`). Deshalb steht die Zahl der geprueften Dinge in
der BEDINGUNG, nicht nur im Meldetext.
KEINE WANDUHR ALS ANNAHME: Alle Daten sind relativ zu heute
gerechnet (`tag(-1)`, `tag(+12)`). Ein festes Datum waere eine
Zeitbombe -- gruen, bis der Kalender es einholt (Projektgedaechtnis,
06.09.2026, gate-oeffnung.mjs).
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-evk-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Eventkarten-Pruefung");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(300_000, "pruef-eventkarte");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:" + PORT;
const PROTOKOLL = join(process.cwd(), "pruef-eventkarte-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ---------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
const heute = new Date();
const tag = (v) => new Date(heute.getTime() + v * 86400000).toISOString().slice(0, 10);
function evt(titel, von, bis, auf, reg) {
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende,"
+ " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von)"
+ " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?)")
.run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
/* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als
Summe. Und die Aufgaben genau so krumm getippt wie im echten Event:
einmal ohne Leerzeichen hinter dem Punkt, einmal mit. */
const laufend = evt("Oktober Event", tag(-1), tag(12),
"·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen",
"· Nur Streams auf dem eigenen Konto\n· Punkte zählen bis 23:59 am letzten Tag");
const kommend = evt("November Challenge", tag(20), tag(45),
"· 30 Live-Stunden", "· Nur eigene Konten");
const vorbei = evt("September Rückblick", tag(-40), tag(-12),
"· 20 Live-Stunden", "· Nur eigene Konten");
/* Ein gewoehnlicher Eintrag auf demselben Brett: Er darf sich NICHT
mitverwandeln. Ohne ihn wuerde "nur Events sind betroffen" hier gar
nicht geprueft. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von)"
+ " VALUES (?,?,'agentur','schulung','offen',?, 'mittel', ?, ?)")
.run("Einarbeitung für neue Modis", "Kein Event.", tag(0), jetzt, idDogi);
/* EIN EINTRAG, DEN LUNA NICHT SEHEN DARF -- fuer die Gegenprobe zur
Bildweg-Reparatur weiter unten. `entwicklung` ist eine Aufzeichnung
UEBER eine Person; sie selbst sieht sie nicht. Ohne diesen Eintrag
waere "es wurden keine Rechte ausgeweitet" eine Behauptung ohne
Beleg. */
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von, creator_id)"
+ " VALUES (?,?,'entwicklung','notiz','offen',?, 'mittel', ?, ?, ?)")
.run("Vertrauliche Aufzeichnung", "Nicht fuer Luna.", tag(0), jetzt, idDogi, idDogi);
const geheim = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.close();
/* Ein Banner fuer das laufende Event -- ohne Bild waere der Knopf
"Banner ganz" nicht pruefbar, und die Buehne mit Bild ist der Fall,
um den es Filipe ging. */
import { pngBauen } from "./helfer-png-bauen.mjs";
const bannerBild = pngBauen(1280, 720, (x, y, b, h) => {
const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.8);
return [Math.round(26 + g * 120), Math.round(10 + g * 30), Math.round(46 + g * 150)];
});
/* ---- Zugang ----------------------------------------------------------- */
const { request } = await import("node:http");
const HOST = "workspace.dogfather-universe.com";
function roh(pfad, o = {}) {
return new Promise((f, s) => {
const daten = o.rumpf === undefined ? null
: (Buffer.isBuffer(o.rumpf) ? o.rumpf : Buffer.from(JSON.stringify(o.rumpf)));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: o.methode || (daten ? "POST" : "GET"),
headers: {
Host: HOST, Origin: "https://" + HOST,
...(o.keks ? { Cookie: o.keks } : {}),
...(daten ? { "Content-Length": daten.length } : {}),
...(o.kopf || {}),
},
}, (r) => {
const t = [];
r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode, kopf: r.headers, text: Buffer.concat(t).toString("utf8") }));
});
a.on("error", s);
if (daten) a.write(daten);
a.end();
});
}
async function anmelden(rolle, code) {
const a = await roh("/workspace/api/anmelden", {
rumpf: { rolle, code }, kopf: { "Content-Type": "application/json" } });
if (a.code !== 200) { melde("ABBRUCH: Anmeldung " + rolle + " -> " + a.code); process.exit(3); }
return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
const brett = async (keks) => JSON.parse((await roh("/workspace/api/bereich/agentur", { keks })).text);
let bannerId = null, geheimBildId = null;
{
const h = await roh("/workspace/api/bereich/agentur/" + laufend + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("banner.png") } });
if (h.code !== 201) {
melde("ABBRUCH: Banner liess sich nicht anhaengen (" + h.code + ") -- ohne es");
melde(" prueft diese Datei die Buehne MIT Bild gar nicht.");
process.exit(3);
}
bannerId = JSON.parse(h.text).id;
const g = await roh("/workspace/api/bereich/entwicklung/" + geheim + "/bild", {
keks: keksDogi, rumpf: bannerBild, methode: "POST",
kopf: { "Content-Type": "image/png", "x-name": encodeURIComponent("geheim.png") } });
if (g.code !== 201) {
melde("ABBRUCH: das Bild fuer die Gegenprobe ging nicht durch (" + g.code + ").");
melde(" Ohne es wuerde unten eine Rechtefrage geprueft, die gar nicht stattfindet.");
process.exit(3);
}
geheimBildId = JSON.parse(g.text).id;
}
melde("\n=== 0. Der Bildweg: wer den Eintrag sieht, sieht sein Bild ===");
{
/* GEMESSEN AM 03.10.2026, und es war ein echter Mangel: Das Banner
eines Agentur-Events kam bei einer Creatorin mit 404 zurueck --
an genau der Karte, die fuer sie gemacht ist. Grund: Die Regel
"wer das Brett sieht, sieht das Bild" galt nur fuer die sieben
Community-Bretter, und danach griff die Ablage-Regel, die an
Creator-Zuordnungen haengt. Ein Agentur-Event hat keine. */
const alsDogi = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksDogi });
const alsLuna = await roh("/workspace/api/dateien/" + bannerId + "/bild", { keks: keksLuna });
ok(alsDogi.code === 200, `DogFather bekommt das Banner (HTTP ${alsDogi.code})`);
ok(alsLuna.code === 200, `und die Creatorin auch (HTTP ${alsLuna.code}, vorher 404)`);
/* DIE GEGENPROBE, auf die es ankommt: Die Reparatur darf NICHT mehr
oeffnen als noetig. Ein Eintrag, den Luna nicht sehen darf, muss
sein Bild weiterhin verweigern -- sonst waere aus einer Luecke
eine groessere geworden. */
const verboten = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksLuna });
ok(verboten.code === 404,
`ein Bild an einem fremden Eintrag bleibt verborgen (HTTP ${verboten.code})`);
/* Und dass dieses Bild ueberhaupt existiert -- sonst waere die
Gegenprobe gruen, weil es nichts zu sehen gab. */
const existiert = await roh("/workspace/api/dateien/" + geheimBildId + "/bild", { keks: keksDogi });
ok(existiert.code === 200,
`es gibt dieses Bild wirklich (DogFather: HTTP ${existiert.code})`);
}
/* =======================================================================
1. Die Punkte: aus Fliesstext wird eine Liste
======================================================================= */
melde("\n=== 1. Die Punkte kommen als Liste vom Server ===");
{
const daten = await brett(keksDogi);
const e = (daten.eintraege || []).find((x) => x.titel === "Oktober Event");
ok(!!e, "das laufende Event ist auf dem Brett");
const p = e?.event_punkte || [];
ok(p.length === 3, `3 Aufgabenpunkte (${p.length})`);
/* DAS IST DER EIGENTLICHE MANGEL AUS FILIPES BILDSCHIRMFOTO: Im
Original stand „·Jeden Tag Live gehen" neben „· 33k Diamanten
erreichen" -- einmal ohne, einmal mit Leerzeichen. */
ok(p[0]?.text === "Jeden Tag Live gehen",
`ohne Leerzeichen getippt -> sauber: ${JSON.stringify(p[0]?.text)}`);
ok(p[2]?.text === "33k Diamanten erreichen",
`mit Leerzeichen getippt -> gleich sauber: ${JSON.stringify(p[2]?.text)}`);
ok(p.every((x) => /^[0-9a-f]{16}$/.test(x.schluessel || "")) && p.length > 0,
"jeder Punkt hat einen Schluessel (16 Hexzeichen)");
ok((e?.event_regelpunkte || []).length === 2,
`2 Regelpunkte (${(e?.event_regelpunkte || []).length})`);
ok(e?.event_geschafft === 0, `noch nichts abgehakt (${e?.event_geschafft})`);
/* GEGENPROBE: Ein Eintrag, der KEIN Event ist, bekommt keine Punkte.
Ohne sie wuesste niemand, ob die Zerlegung nicht einfach ueberall
laeuft -- und dann waere "nur Events sind betroffen" unbelegt. */
const schulung = (daten.eintraege || []).find((x) => x.art === "schulung");
ok(!!schulung, "der Nicht-Event-Eintrag ist da (sonst prueft die Gegenprobe nichts)");
ok(schulung && schulung.event_punkte === undefined,
"ein Eintrag ohne Eventfelder bekommt keine Punkteliste");
}
/* =======================================================================
2. Abhaken -- und was dabei NICHT geht
======================================================================= */
melde("\n=== 2. Abhaken: jeder fuer sich ===");
let schluesselA = null, schluesselB = null;
{
const daten = await brett(keksDogi);
const e = daten.eintraege.find((x) => x.titel === "Oktober Event");
schluesselA = e.event_punkte[1].schluessel; /* 22-Live-Stunden */
schluesselB = e.event_punkte[2].schluessel; /* 33k Diamanten */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Luna hakt einen Punkt ab (HTTP ${a.code})`);
const j = JSON.parse(a.text || "{}");
ok(j.geschafft === 1 && j.gesamt === 3, `Antwort sagt ${j.geschafft} von ${j.gesamt} (erwartet 1 von 3)`);
/* HAELT ES AUCH? Die Zahl aus der Antwort koennte gerechnet sein,
ohne dass etwas gespeichert wurde. Deshalb ein frischer Abruf. */
const nachher = await brett(keksLuna);
const eL = nachher.eintraege.find((x) => x.titel === "Oktober Event");
ok(eL.event_geschafft === 1, `nach frischem Abruf: ${eL.event_geschafft} abgehakt`);
ok(eL.event_punkte.filter((p) => p.meiner).length === 1
&& eL.event_punkte[1].meiner === true,
"und zwar genau der angeklickte Punkt");
/* DER KERN DER ZUSAGE: DogFather sieht Lunas Haken NICHT als seinen
eigenen. Ohne diese Pruefung waere "jeder hakt fuer sich ab" eine
Behauptung. */
const beiDogi = await brett(keksDogi);
const eD = beiDogi.eintraege.find((x) => x.titel === "Oktober Event");
ok(eD.event_geschafft === 0,
`DogFather sieht Lunas Haken nicht als seinen (${eD.event_geschafft})`);
ok(eD.event_punkte.every((p) => p.meiner === false),
"kein einziger Punkt ist bei ihm angehakt");
/* ... sieht aber, DASS jemand dabei ist -- er ist die Leitung. */
ok(Array.isArray(eD.event_stand) && eD.event_stand.length === 1
&& eD.event_stand[0].name === "Luna" && eD.event_stand[0].geschafft === 1,
`die Leitung sieht den Stand: ${JSON.stringify(eD.event_stand)}`);
/* GEGENPROBE ZUR LEITUNG: Luna selbst darf diese Liste NICHT
bekommen -- sonst waere "wer macht mit" eine Auskunft fuer alle. */
ok(eL.event_stand === undefined,
"eine Creatorin bekommt die Mitmacherliste gar nicht erst");
/* Zweimal dasselbe ist kein Fehler, aendert aber auch nichts. */
const nochmal = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
const j2 = JSON.parse(nochmal.text || "{}");
ok(nochmal.code === 200 && j2.geschafft === 1,
`zweimal derselbe Haken bleibt 1 (HTTP ${nochmal.code}, ${j2.geschafft})`);
/* Und wieder weg. */
const ab = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "DELETE" });
const j3 = JSON.parse(ab.text || "{}");
ok(ab.code === 200 && j3.geschafft === 0, `Haken wieder weg (${j3.geschafft})`);
/* Fuer die folgenden Pruefungen wieder setzen. */
await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselA}`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
}
melde("\n=== 2b. Was NICHT geht ===");
{
/* EIN ERFUNDENER SCHLUESSEL. Ohne diese Schranke koennte sich jeder
„3 von 3" setzen, ohne dass eine der drei Aufgaben je dastand --
und eine Zahl, die man sich ausdenken kann, zaehlt nichts. */
const a = await roh(`/workspace/api/eintrag/${laufend}/punkt/00112233445566ff`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(a.code === 404, `erfundener Punkt wird abgelehnt (HTTP ${a.code})`);
/* Unsinn in der Adresse. */
const b = await roh(`/workspace/api/eintrag/${laufend}/punkt/..%2F..%2Fetc`, {
keks: keksLuna, methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(b.code === 400 || b.code === 404, `kein Schluesselformat -> abgelehnt (HTTP ${b.code})`);
/* Ohne Anmeldung gar nichts. */
const c = await roh(`/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`, {
methode: "PUT", rumpf: {}, kopf: { "Content-Type": "application/json" } });
ok(c.code === 401, `ohne Anmeldung abgewiesen (HTTP ${c.code})`);
/* Fremde Herkunft (CSRF). */
const e = await new Promise((f, s) => {
const a2 = request({
host: "127.0.0.1", port: PORT,
path: `/workspace/api/eintrag/${laufend}/punkt/${schluesselB}`,
method: "PUT",
headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksLuna,
"Content-Type": "application/json", "Content-Length": 2 },
}, (r) => { const t = []; r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode })); });
a2.on("error", s); a2.write("{}"); a2.end();
});
ok(e.code === 403, `fremde Herkunft abgewiesen (HTTP ${e.code})`);
}
/* =======================================================================
3. Der Schluessel ueberlebt das Umsortieren -- aber nicht das Umschreiben
======================================================================= */
melde("\n=== 3. Haken bleiben am richtigen Punkt ===");
{
/* UMSORTIEREN: Der Haken an „22-Live-Stunden" muss bleiben, wo er
ist -- auch wenn eine Zeile DAVOR eingefuegt wird. Haengt er an
einer Zeilennummer, wandert er still auf eine andere Aufgabe, und
jemand hat etwas abgehakt, was er nie gemacht hat. */
const a = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 22-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(a.code === 200, `Aufgaben umsortiert und ergaenzt (HTTP ${a.code})`);
const nach = await brett(keksLuna);
const e = nach.eintraege.find((x) => x.titel === "Oktober Event");
ok((e.event_punkte || []).length === 4, `jetzt 4 Punkte (${(e.event_punkte || []).length})`);
const haken = (e.event_punkte || []).filter((p) => p.meiner);
ok(haken.length === 1 && haken[0].text === "22-Live-Stunden erreichen",
`der Haken sitzt weiterhin an: ${JSON.stringify(haken.map((h) => h.text))}`);
/* GEGENPROBE: Wird die Bedingung SELBST geaendert, muss der Haken
fallen -- eine geaenderte Bedingung ist eine andere Bedingung, und
ein Haken daran waere eine Falschauskunft. */
const b = await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "· GANZ NEU ganz oben\n· 33k Diamanten erreichen\n· 30-Live-Stunden erreichen\n· Jeden Tag Live gehen" },
kopf: { "Content-Type": "application/json" } });
ok(b.code === 200, `Bedingung umgeschrieben: 22 -> 30 Stunden (HTTP ${b.code})`);
const nach2 = await brett(keksLuna);
const e2 = nach2.eintraege.find((x) => x.titel === "Oktober Event");
ok((e2.event_punkte || []).filter((p) => p.meiner).length === 0,
"der Haken ist gefallen -- 30 Stunden sind nicht 22");
ok(e2.event_geschafft === 0, `und die Zahl stimmt dazu (${e2.event_geschafft})`);
/* Zurueckstellen fuer die Oberflaechenpruefung. */
await roh(`/workspace/api/bereich/agentur/${laufend}`, {
keks: keksDogi, methode: "PATCH",
rumpf: { event_aufgaben: "·Jeden Tag Live gehen\n·22-Live-Stunden erreichen\n· 33k Diamanten erreichen" },
kopf: { "Content-Type": "application/json" } });
}
/* =======================================================================
4. Die Oberflaeche
======================================================================= */
melde("\n=== 4. Die Karte im Browser ===");
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const klagen = [];
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 1000 }, hasTouch: breite <= 860 });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: HOST } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("pageerror: " + f.message));
seite.on("console", (m) => { if (m.type() === "error") klagen.push("console: " + m.text().slice(0, 160)); });
/* WELCHE Datei fehlt -- die Konsolenmeldung sagt nur "404 (Not
Found)". Beim ersten Lauf standen hier drei davon, und sie waren
nicht zuzuordnen; eine Pruefung, die nicht sagen kann, woran sie
scheitert, kostet eine Stunde Suchen (Projektgedaechtnis,
11.09.2026). */
seite.on("response", (r) => {
if (r.status() >= 400) klagen.push(`HTTP ${r.status()}: ${new URL(r.url()).pathname}`);
});
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="' + rolle + '"]');
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForSelector(".eintrag-karte", { timeout: 15000 });
await seite.waitForTimeout(700);
return { ctx, seite };
}
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
const karten = await seite.$$('.eintrag-karte[data-eventkarte="ja"]');
ok(karten.length === 3, `3 Eventkarten (${karten.length}) -- die Schulung ist keine`);
/* GEGENPROBE: Die Schulung bleibt eine gewoehnliche Karte. Ohne sie
waere "nur Events sind umgebaut" unbelegt. */
const schulung = await seite.$('.eintrag-karte:not([data-eventkarte="ja"])');
ok(!!schulung, "der Nicht-Event-Eintrag ist weiterhin eine gewoehnliche Karte");
const masse = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const b = k.querySelector(".ev-buehne");
const bild = k.querySelector(".ev-buehne__bild");
const t = k.querySelector(".eintrag-titel");
const kr = k.getBoundingClientRect();
const tr = t.getBoundingClientRect();
return {
karte: Math.round(kr.height),
buehne: b ? Math.round(b.getBoundingClientRect().height) : 0,
bildBreite: bild ? Math.round(bild.getBoundingClientRect().width) : 0,
buehneBreite: b ? Math.round(b.getBoundingClientRect().width) : 0,
titelGroesse: parseFloat(getComputedStyle(t).fontSize),
textGroesse: parseFloat(getComputedStyle(k.querySelector(".eintrag-text")).fontSize),
markeGroesse: parseFloat(getComputedStyle(k.querySelector(".marke-art")).fontSize),
/* Wie weit oben steht der Titel INNERHALB der Karte? Das war der
eigentliche Mangel: vorher unter 638 px Bild. */
titelInKarte: Math.round(tr.top - kr.top),
datumZeilen: [...k.querySelectorAll(".ev-spanne, .im-kalender, .event-zeitraum")]
.map((x) => x.textContent.trim()),
noch: k.querySelector(".ev-noch")?.textContent || null,
};
});
melde(" -- gemessen: " + JSON.stringify(masse));
/* DIE ZAHLEN AUS DEM BEFUND, jede einzeln. */
ok(masse.titelInKarte < 180,
`der Titel steht ${masse.titelInKarte} px unter der Kartenkante (vorher 638+, Grenze 180)`);
ok(masse.titelGroesse >= 18,
`Titel ${masse.titelGroesse} px (vorher 15,36 -- Grenze 18)`);
ok(masse.titelGroesse >= masse.textGroesse * 1.3,
`Titel deutlich groesser als der Text darunter (${masse.titelGroesse} vs ${masse.textGroesse})`);
/* Die projekteigene Grenze fuer Grossbuchstaben mit Sperrung. Sie
galt bisher nur unter 760 px Fensterbreite -- eine Grenze, die nur
bei schmalen Fenstern gilt, ist keine. */
ok(masse.markeGroesse >= 11.5,
`Etikett ${masse.markeGroesse} px (vorher 10,88 -- Grenze 11,5)`);
ok(masse.buehne <= 320 && masse.buehne > 0,
`Buehne ${masse.buehne} px hoch (vorher 638)`);
ok(masse.datumZeilen.length === 1,
`das Datum steht einmal, nicht dreimal: ${JSON.stringify(masse.datumZeilen)}`);
ok(/^noch \d+ Tage?$/.test(masse.noch || ""),
`der Countdown beantwortet "wie lange noch": ${JSON.stringify(masse.noch)}`);
/* Die drei Zustaende -- jeder einzeln nachgewiesen, nicht als Summe. */
const staende = await seite.evaluate(() => {
const raus = {};
for (const k of document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')) {
const t = k.querySelector(".eintrag-titel")?.textContent || "?";
raus[t] = { stand: k.dataset.event, wort: k.querySelector(".ev-noch")?.textContent };
}
return raus;
});
ok(staende["Oktober Event"]?.stand === "laeuft",
`laufend: ${JSON.stringify(staende["Oktober Event"])}`);
ok(staende["November Challenge"]?.stand === "kommt"
&& /^startet in \d+ Tagen$/.test(staende["November Challenge"]?.wort || ""),
`kommend: ${JSON.stringify(staende["November Challenge"])}`);
ok(staende["September Rückblick"]?.stand === "vorbei"
&& /^vorbei seit \d+ Tagen$/.test(staende["September Rückblick"]?.wort || ""),
`vorbei: ${JSON.stringify(staende["September Rückblick"])}`);
/* EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT -- ein Haken
waere eine Behauptung ueber etwas, das nicht mehr stattfindet. */
const knoepfeVorbei = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("September"));
return {
knoepfe: k.querySelectorAll(".ev-punkt__knopf").length,
zeilen: k.querySelectorAll(".ev-punkt").length,
};
});
ok(knoepfeVorbei.zeilen > 0 && knoepfeVorbei.knoepfe === 0,
`vorbei: ${knoepfeVorbei.zeilen} Zeilen lesbar, ${knoepfeVorbei.knoepfe} Knoepfe`);
/* DER WEG ZUM GANZEN BANNER. Er war beim ersten Entwurf verloren
gegangen: Vorher war das Bild ein Verweis, als Buehne nicht mehr --
und bei Filipes Banner steht die eigentliche Ansage IM Bild. */
const ganz = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const a = k.querySelector(".ev-buehne__ganz");
const t = k.querySelector(".eintrag-titel");
if (!a) return null;
const ar = a.getBoundingClientRect(), tr = t.getBoundingClientRect();
return {
ziel: a.getAttribute("href"),
neuesFenster: a.getAttribute("target") === "_blank" && /noopener/.test(a.getAttribute("rel") || ""),
wort: a.textContent.trim(),
/* Deckt er den Titel zu? Das haeufigste Problem bei einem Knopf,
der absolut in einer Ecke sitzt. */
ueberlappt: !(ar.bottom < tr.top || ar.top > tr.bottom || ar.right < tr.left || ar.left > tr.right),
hoehe: Math.round(ar.height),
};
});
ok(!!ganz && /^\/workspace\/api\/dateien\/\d+\/bild$/.test(ganz.ziel || ""),
`"Banner ganz" fuehrt zum vollen Bild: ${JSON.stringify(ganz)}`);
ok(!!ganz && ganz.neuesFenster, "und zwar in einem neuen Fenster, mit noopener");
ok(!!ganz && !ganz.ueberlappt, "er liegt nicht ueber dem Titel");
/* GEGENPROBE: Ohne Bild gibt es ihn nicht -- ein Knopf, der auf eine
Fehlerseite fuehrt, ist schlimmer als keiner. */
const ohneBild = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("November Challenge"));
return { mitbild: k.querySelector(".ev-buehne")?.dataset.mitbild || null,
knopf: k.querySelectorAll(".ev-buehne__ganz").length };
});
ok(ohneBild.mitbild === null && ohneBild.knopf === 0,
`ohne Banner kein Knopf: ${JSON.stringify(ohneBild)}`);
/* Der Loeschknopf unterscheidet sich jetzt von den anderen. */
const gefahr = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const l = k.querySelector(".schritt--gefahr");
const andere = k.querySelector(".eintrag-knoepfe .schritt:not(.schritt--gefahr)");
return l && andere
? { l: getComputedStyle(l).color, a: getComputedStyle(andere).color, text: l.textContent.trim() }
: null;
});
ok(!!gefahr && gefahr.text === "löschen" && gefahr.l !== gefahr.a,
`"löschen" sieht anders aus als die uebrigen Knoepfe: ${JSON.stringify(gefahr)}`);
await ctx.close();
}
melde("\n=== 5. Abhaken im Browser, mit Neuladen ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 1280);
/* DEN ZAEHLER DER RICHTIGEN KARTE LESEN.
Beim ersten Lauf stand hier `seite.textContent(".ev-zaehler")` --
das nimmt den ERSTEN auf der Seite, und der gehoerte zur November-
Challenge ("0 von 1"). Die Pruefung meldete daraufhin FEHL, obwohl
am Zaehler nichts falsch war. Eine Pruefung, die das Falsche misst,
kostet dieselbe Zeit wie ein echter Fehler. */
const OKT = '.eintrag-karte[data-eventkarte="ja"]:has(.eintrag-titel:text-is("Oktober Event"))';
const zaehlerLesen = async () =>
(await seite.textContent(OKT + " .ev-zaehler") || "").trim();
const vorher = await zaehlerLesen();
ok(/^\d+ von \d+$/.test(vorher), `Zaehler steht da: ${JSON.stringify(vorher)}`);
/* Luna hat aus Teil 2 noch einen Haken -- also 1 von 3. */
ok(vorher === "1 von 3", `Lunas Stand vom Server: ${vorher}`);
const knoepfe = await seite.$$(OKT + " .ev-punkt__knopf");
ok(knoepfe.length === 3, `${knoepfe.length} abhakbare Punkte an diesem Event`);
/* Den NICHT bereits abgehakten nehmen -- sonst zaehlt der Klick
herunter statt herauf, und die Pruefung misst das Gegenteil. */
await knoepfe[0].click();
await seite.waitForTimeout(600);
const danach = await zaehlerLesen();
ok(danach === "2 von 3", `nach dem Klick: ${danach}`);
/* HAELT ES EINEN SEITENWECHSEL AUS? Eine Anzeige, die nur im
Browser zaehlt, sieht genauso aus -- und ist nichts wert. */
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForSelector(".ev-zaehler", { timeout: 15000 });
await seite.waitForTimeout(500);
const nachNeuladen = await zaehlerLesen();
ok(nachNeuladen === "2 von 3", `nach dem Neuladen immer noch: ${nachNeuladen}`);
/* Und wieder ab. */
const k2 = await seite.$$(OKT + " .ev-punkt__knopf");
await k2[0].click();
await seite.waitForTimeout(600);
const ab = await zaehlerLesen();
ok(ab === "1 von 3", `wieder abgewaehlt: ${ab}`);
await ctx.close();
}
melde("\n=== 6. Das Formular ===");
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
await seite.click("#neu-oeffnen");
await seite.waitForTimeout(250);
await seite.selectOption("#f-art", "kampagne");
await seite.waitForTimeout(350);
const editoren = await seite.$$(".zeileneditor");
ok(editoren.length === 2, `zwei Zeilen-Editoren (Aufgaben, Regeln): ${editoren.length}`);
/* DIE TEXTGESTALTUNGS-LEISTE GEHOERT ZUM VERBORGENEN FELD und darf
nicht allein unter der Punkteliste schweben. */
const leisten = await seite.evaluate(() => {
const raus = [];
for (const id of ["f-aufgaben", "f-regeln"]) {
const f = document.getElementById(id);
const l = f?.parentElement?.querySelector(".textform");
raus.push({ id, feldVerborgen: !!f?.hidden, leisteVerborgen: l ? !!l.hidden : null });
}
return raus;
});
ok(leisten.length === 2 && leisten.every((x) => x.feldVerborgen && x.leisteVerborgen === true),
`Leiste ist mit ihrem Feld verborgen: ${JSON.stringify(leisten)}`);
/* Platzhalter passend zum Feld -- "22 Live-Stunden" unter REGELN
waere eine Fehlanleitung. */
const halter = await seite.evaluate(() =>
[...document.querySelectorAll(".zeileneditor")].map((r) =>
r.querySelector(".zeileneditor__feld")?.placeholder || ""));
ok(halter.length === 2 && halter[0] !== halter[1]
&& /Live-Stunden/.test(halter[0]) && /Konto/.test(halter[1]),
`je Feld ein eigenes Beispiel: ${JSON.stringify(halter)}`);
await seite.fill("#f-titel", "Testevent");
await seite.fill("#f-datum", tag(0));
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
const dauer = (await seite.textContent("#hinweis-dauer") || "").trim();
ok(dauer === "Läuft 14 Tage.", `Dauer wird mitgerechnet: ${JSON.stringify(dauer)}`);
/* GEGENPROBE: Ein Ende VOR dem Anfang muss auffallen -- sonst stuende
auf der frischen Karte "vorbei seit 3 Tagen". */
await seite.fill("#f-ende", tag(-3));
await seite.waitForTimeout(250);
const warnung = (await seite.textContent("#hinweis-dauer") || "").trim();
const warnMerkmal = await seite.getAttribute("#hinweis-dauer", "data-warnung");
ok(/vor dem Anfang/.test(warnung) && warnMerkmal === "ja",
`verdrehter Zeitraum faellt auf: ${JSON.stringify(warnung)}`);
await seite.fill("#f-ende", tag(13));
await seite.waitForTimeout(250);
/* Die Vorschau zeigt die echte Buehne. */
const vorschau = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return {
sichtbar: v ? !v.hidden : false,
buehne: !!v?.querySelector(".ev-buehne"),
titel: v?.querySelector(".eintrag-titel")?.textContent || null,
noch: v?.querySelector(".ev-noch")?.textContent || null,
};
});
ok(vorschau.sichtbar && vorschau.buehne && vorschau.titel === "Testevent"
&& /^noch \d+ Tage?$/.test(vorschau.noch || ""),
`Vorschau zeigt die echte Buehne: ${JSON.stringify(vorschau)}`);
/* DER KERN DES FORMULAR-UMBAUS: Was am Ende gespeichert wird, ist
gleichmaessig -- egal wie jemand tippt. */
const felder = await seite.$$(".zeileneditor__feld");
await felder[0].fill("Jeden Tag Live gehen");
await seite.click(".zeileneditor__mehr");
const f2 = await seite.$$(".zeileneditor__feld");
await f2[1].fill("22 Live-Stunden erreichen");
await seite.waitForTimeout(250);
const roh2 = await seite.inputValue("#f-aufgaben");
ok(roh2 === "· Jeden Tag Live gehen\n· 22 Live-Stunden erreichen",
`gleichmaessig gesetzte Zeichen: ${JSON.stringify(roh2)}`);
/* Und das Ganze wirklich abschicken -- bis auf die Karte. */
await seite.click("#neu-speichern");
await seite.waitForTimeout(1200);
/* IM BRETT SUCHEN, NICHT IM FORMULAR.
Beim ersten Lauf stand hier ein Suchlauf ueber ALLE Karten der
Seite -- und die VORSCHAU im Formular ist ebenfalls eine Karte mit
demselben Merkmal und demselben Titel. Gefunden wurde sie zuerst,
und sie hat naturgemaess keine Aufgabenliste. Die Pruefung meldete
einen Fehler, den es nicht gab. */
const neu = await seite.evaluate(() => {
const liste = document.getElementById("liste");
const k = [...liste.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Testevent"));
return k ? {
punkte: [...k.querySelectorAll(".ev-block--aufgaben .ev-punkt__text")].map((x) => x.textContent),
zaehler: k.querySelector(".ev-zaehler")?.textContent || null,
} : null;
});
ok(!!neu && neu.punkte.length === 2
&& neu.punkte[0] === "Jeden Tag Live gehen"
&& neu.punkte[1] === "22 Live-Stunden erreichen",
`aus dem Editor wurde die Liste auf der Karte: ${JSON.stringify(neu)}`);
ok(neu?.zaehler === "0 von 2", `und der Zaehler steht auf null: ${neu?.zaehler}`);
/* NACH DEM SPEICHERN IST DIE VORSCHAU LEER. Sonst stuende beim
naechsten "Neuer Eintrag" das vorige Event darin -- und man
glaubte, es sei nicht gespeichert worden. */
const nachher = await seite.evaluate(() => {
const v = document.getElementById("ev-vorschau");
return { verborgen: !!v?.hidden, inhalt: (v?.textContent || "").trim().length };
});
ok(nachher.verborgen && nachher.inhalt === 0,
`die Vorschau ist zurueckgesetzt: ${JSON.stringify(nachher)}`);
await ctx.close();
}
melde("\n=== 7. Auf dem Handy (412 px) ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 412);
const handy = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const knopf = k.querySelector(".ev-punkt__knopf");
const b = k.querySelector(".ev-buehne");
return {
buehne: Math.round(b.getBoundingClientRect().height),
knopfHoehe: knopf ? Math.round(knopf.getBoundingClientRect().height) : 0,
/* Laeuft etwas ueber den Rand? Das haeufigste Handy-Problem. */
ueberlauf: Math.round(k.scrollWidth - k.clientWidth),
bloeckeSpalten: getComputedStyle(k.querySelector(".ev-bloecke")).gridTemplateColumns,
ganzHoehe: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().height) : null; })(),
ganzBreite: (() => { const a = k.querySelector(".ev-buehne__ganz");
return a ? Math.round(a.getBoundingClientRect().width) : null; })(),
};
});
melde(" -- gemessen: " + JSON.stringify(handy));
ok(handy.knopfHoehe >= 44,
`das Beruehrziel ist ${handy.knopfHoehe} px hoch (Grenze 44)`);
/* UND WENN DOCH, DANN WELCHES ELEMENT? Eine Zahl allein schickt
einen auf die Suche; der Schuldige steht im DOM und wird nur nicht
genannt. (Projektgedaechtnis: eine Pruefung, die nicht sagen kann,
woran sie scheitert, kostet eine Stunde.) */
if (handy.ueberlauf > 1) {
const taeter = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Oktober Event"));
const kr = k.getBoundingClientRect();
return [...k.querySelectorAll("*")]
.map((e) => ({ was: e.className || e.tagName,
rechts: Math.round(e.getBoundingClientRect().right - kr.right) }))
.filter((x) => x.rechts > 0)
.sort((a, b) => b.rechts - a.rechts).slice(0, 5);
});
melde(" -- was ueberlaeuft: " + JSON.stringify(taeter)
+ (taeter.length === 0
? " (KEIN Element -- dann ist es ein ::before/::after, das"
+ " querySelectorAll nicht findet. Beim ersten Mal war es"
+ " .ev-buehne__inhalt::before mit festem -20px gegen 14px Polsterung.)"
: ""));
}
ok(handy.ueberlauf <= 1, `nichts laeuft ueber den Rand (${handy.ueberlauf} px)`);
ok(handy.bloeckeSpalten.split(" ").length === 1,
`auf 412 px eine Spalte, nicht zwei: ${handy.bloeckeSpalten}`);
ok(handy.buehne <= 240 && handy.buehne > 0, `Buehne ${handy.buehne} px`);
/* Auf dem Handy schrumpft "Banner ganz" auf die Lupe -- das Ziel
darf dabei nicht unter 32 px fallen. (32 statt 44: Er sitzt frei
in einer Ecke ohne Nachbarn, und sein Wort bleibt fuer
Vorleseprogramme erhalten.) */
ok(handy.ganzHoehe !== null && handy.ganzHoehe >= 32 && handy.ganzBreite >= 32,
`der Banner-Knopf bleibt greifbar: ${handy.ganzBreite} x ${handy.ganzHoehe} px`);
await ctx.close();
}
melde("\n=== 8. Das andere Haus bleibt unberuehrt ===");
{
/* DIE REGEL VOM 24.09.2026: "Ab sofort wird JEDER Umbau getrennt
gefuehrt. Eine Aenderung an einem der beiden Haeuser darf im
anderen nichts bewirken ... Wer etwas baut, sagt vorher, fuer
WELCHES Haus es ist, und weist danach nach, dass das andere
unveraendert ist."
DAS IST HIER NICHT SELBSTVERSTAENDLICH: `bereich.css` wird von
JEDEM Brett geladen, auch von denen der Crew, und `tuKnopf` ist
die Knopffabrik aller Bretter. Beides wurde angefasst. Dass
trotzdem nur Agentur-Events betroffen sind, haengt daran, dass
jede neue Regel an `[data-eventkarte="ja"]` oder einer `ev-`
Klasse haengt -- und dieses Merkmal setzt nur istEventArt().
Eine Behauptung, solange sie niemand nachmisst. */
const ctx = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
await ctx.route("**/*", (route) => {
const u = new URL(route.request().url());
if (u.hostname === "127.0.0.1") {
return route.continue({ headers: { ...route.request().headers(), host: "crew.dogfather-universe.com" } });
}
return route.continue();
});
const seite = await ctx.newPage();
seite.on("pageerror", (f) => klagen.push("crew pageerror: " + f.message));
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
/* Das Brett der Agentur gibt es auf dieser Adresse gar nicht. */
await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const dort = await seite.evaluate(() => ({
adresse: location.pathname,
eventkarten: document.querySelectorAll('[data-eventkarte="ja"]').length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
/* ==== EIN BEFUND, DER NICHT AUS DIESEM UMBAU STAMMT ===============
Hier stand zuerst die Zusage "auf der Crew-Adresse nichts davon" --
und sie wurde rot: Das Agentur-Brett ist dort erreichbar, mit
Inhalt. Vor dem Schreiben einer Reparatur erst nachgemessen, ob
ich das verursacht habe (03.10.2026, server/_mess/haus.mjs, einmal
mit und einmal ohne den Umbau):
Host crew.dogfather-universe.com haus=crew
/workspace/api/bereich/agentur -> 200, 1 Eintrag
ZWEIMAL DASSELBE, mit und ohne. Der Durchgriff ist also aelter als
dieser Umbau und gehoert zur Regel vom 24.09.2026 ("Kein Bestand
wird geteilt"), nicht hierher -- er ist Filipe gemeldet und wird
getrennt entschieden. Ihn hier nebenbei zuzumauern hiesse, am
Rechtesystem beider Haeuser zu drehen, waehrend eigentlich eine
Karte umgebaut wird.
WAS DIESE DATEI DESHALB ZUSAGT, ist das, wofuer sie zustaendig
ist: Der Umbau aendert an der Crew-Adresse NICHTS -- die Karten
sehen dort aus wie auf der Agenturadresse, und die eigenen Bretter
der Crew bleiben unberuehrt (gleich darunter).
Die Zahl wird trotzdem ausgegeben: Faellt sie eines Tages auf 0,
ist der Durchgriff behoben, und das soll man hier sehen. */
melde(" -- Crew-Adresse, Brett `agentur`: " + JSON.stringify(dort)
+ (dort.eventkarten > 0
? " (vorbestehender Durchgriff, siehe Kommentar -- nicht aus diesem Umbau)"
: " (der Durchgriff ist behoben)"));
/* Wenn das Brett dort erscheint, dann wenigstens vollstaendig und
ohne Fehler -- eine halb gebaute Karte waere ein echter Schaden. */
ok(dort.eventkarten === 0 || dort.evTeile > 0,
`was dort erscheint, ist vollstaendig gebaut (${dort.eventkarten} Karten, ${dort.evTeile} Teile)`);
/* Und ein Brett, das es dort WIRKLICH gibt, sieht aus wie immer.
Ohne diese Haelfte waere oben nur bewiesen, dass die Seite leer
ist -- das waere sie auch bei einem kaputten Brett. */
await seite.goto(BASIS + "/workspace/bereich.html?b=treff", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const treff = await seite.evaluate(() => ({
liste: !!document.getElementById("liste"),
formular: !!document.getElementById("neu"),
knoepfe: document.querySelectorAll(".schritt").length,
gefahr: document.querySelectorAll(".schritt--gefahr").length,
evTeile: document.querySelectorAll('[class*="ev-buehne"], [class*="ev-punkt"], .zeileneditor').length,
}));
ok(treff.liste && treff.formular,
`ein Crew-Brett steht und funktioniert: ${JSON.stringify(treff)}`);
ok(treff.evTeile === 0, `und zeigt keinen Teil des Event-Umbaus (${treff.evTeile})`);
/* Auch der Loeschknopf bleibt dort der alte: Die Markierung wird
zwar ueberall gesetzt, aber gestaltet wird sie nur innerhalb einer
Eventkarte. */
const farben = await seite.evaluate(() => {
const g = document.querySelector(".schritt--gefahr");
const n = document.querySelector(".schritt:not(.schritt--gefahr)");
return g && n ? { gefahr: getComputedStyle(g).color, normal: getComputedStyle(n).color } : null;
});
ok(farben === null || farben.gefahr === farben.normal,
`der Loeschknopf sieht dort aus wie immer: ${JSON.stringify(farben)}`);
await ctx.close();
}
await browser.close();
melde("\n=== Browser-Meldungen ===");
ok(klagen.length === 0, klagen.length ? "Klagen: " + klagen.join(" | ") : "keine Fehler in der Konsole");
melde("");
melde(`${geprueft} geprueft, ${fehler} Fehler`);
if (!geprueft) { melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); process.exit(3); }
process.exit(fehler ? 1 : 0);
+35 -37
View File
@@ -512,42 +512,40 @@ melde("\n=== Anmelden und wieder abmelden ===");
const zahl = () => p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n; const zahl = () => p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
ok(zahl() === 0, `vorher ${zahl()} Anmeldungen in der Datenbank`); ok(zahl() === 0, `vorher ${zahl()} Anmeldungen in der Datenbank`);
/* ==== "BEI DIR KLINGELT NICHTS" (19.09.2026) ====================== /* ==== "BEI DIR KLINGELT NICHTS" IST HIER WEG (03.10.2026) ========
Gemessen an diesem Tag auf dem echten Server: Von elf Zugaengen Bis heute stand dieser Hinweis in "Was ist dran?", eingefuehrt am
hatten SIEBEN kein einziges Geraet angemeldet. Bei ihnen kommt 19.09., als sieben von elf Zugaengen kein Geraet hatten. Er hat
nichts an, solange die Seite zu ist -- keine Aufgabe, kein Termin, das Problem nicht geloest: Am 03.10. nachgemessen waren es immer
kein Anruf. noch 12 von 20, darunter die linke Hand und ein Modi. Er SAGT es,
aber man kann nichts tun -- er fuehrt nur auf eine andere Seite.
Die Glocke sagt das seit jeher korrekt. Nur tippt sie niemand an: Ersetzt durch einen Kasten mit "Anschalten"-Knopf, direkt ueber
Sie ist ein stiller Schalter in einer Leiste. Deshalb steht es dieser Liste (gemessen in pruef-push-hinweis.mjs, 16 Pruefungen).
jetzt auch dort, wo man hinsieht -- in "Was ist dran?".
DIE GEGENPROBE IST DER EIGENTLICHE PUNKT: Der Hinweis muss WAS HIER JETZT GEPRUEFT WIRD, ist das Gegenteil von vorher, und
VERSCHWINDEN, sobald ein Geraet da ist. Eine Warnung, die immer zwar aus einem Grund: Aufgefallen ist die Dopplung erst auf einem
kommt, ist keine Warnung mehr -- und bei sieben von elf Leuten Bildschirmfoto -- beide Saetze standen untereinander auf
stuende sie sonst dauerhaft ueber den echten. */ demselben Schirm. Zahlen haetten das nie gezeigt, beide Teile
waren fuer sich genommen gruen. Diese Pruefung sorgt dafuer, dass
die Zeile nicht unbemerkt zurueckkommt. */
const hinweise = async () => (await seite.evaluate(async () => { const hinweise = async () => (await seite.evaluate(async () => {
const a = await fetch("/workspace/api/hinweise", { credentials: "same-origin" }); const a = await fetch("/workspace/api/hinweise", { credentials: "same-origin" });
return a.json(); return a.json();
})).hinweise || []; })).hinweise || [];
{ {
const vorher = await hinweise(); const offen = await hinweise();
const h = vorher.find((x) => x.art === "kein_geraet"); /* Die Zahl gehoert in die Bedingung: Kaeme die Schnittstelle gar
ok(!!h, "ohne Geraet steht der Hinweis in „Was ist dran?“"); nicht zustande, waere die Liste leer -- und "kein_geraet steht
ok(h?.ziel === "anruf-probe.html", nicht drin" waere gruen, ohne etwas gesehen zu haben. */
`und er fuehrt auf die Seite, die sagt warum (${h?.ziel})`); ok(Array.isArray(offen) && !offen.some((x) => x.art === "kein_geraet"),
ok(h?.stufe === "offen", `"Bei dir klingelt nichts" steht nicht mehr in der Liste `
`als offener Punkt, nicht als Warnung (${h?.stufe}) — sonst stuende er ` + `(${offen.length} Hinweise geprueft) -- es stuende sonst zweimal auf einem Schirm`);
+ "dauerhaft ueber den echten Warnungen"); const aufDerSeite = await seite.evaluate(() =>
/* Und auf dem Bildschirm steht er ohne die "1" davor: Er zaehlt
nichts, er beschreibt einen Zustand. */
const sichtbar = await seite.evaluate(() =>
[...document.querySelectorAll(".dran__punkt")] [...document.querySelectorAll(".dran__punkt")]
.map((li) => li.textContent.trim()).find((t) => /klingelt nichts/.test(t)) || ""); .filter((li) => /klingelt nichts/.test(li.textContent)).length);
ok(/klingelt nichts/.test(sichtbar) && !/^\s*1/.test(sichtbar), ok(aufDerSeite === 0, `und auch nicht auf der Seite (${aufDerSeite})`);
`auf der Seite steht er ohne Zahl davor: „${sichtbar.replace(/\s+/g, " ").slice(0, 60)}“`);
} }
await seite.click("#glocke"); await seite.click("#glocke");
@@ -555,17 +553,17 @@ melde("\n=== Anmelden und wieder abmelden ===");
ok(zahl() === 1, `nach dem Klick: ${zahl()} Anmeldung`); ok(zahl() === 1, `nach dem Klick: ${zahl()} Anmeldung`);
/* GEGENPROBE: Jetzt muss der Hinweis weg sein. */ /* Hier stand bis zum 03.10.2026 die Gegenprobe "mit angemeldetem
{ Geraet ist der Hinweis weg". Sie ist ersatzlos entfallen -- nicht
/* OHNE NEULADEN. Der erste Anlauf lud die Seite neu -- danach war aus Bequemlichkeit, sondern weil sie seit dem Entfernen des
das Test-Doppel fuer den Push-Dienst weg, der Glockenknopf stand Hinweises IMMER gruen waere, egal was das Haus tut. Eine
wieder auf "aus", und die drei folgenden Pruefungen meldeten Pruefung, die nichts mehr widerlegen kann, bestaetigt nichts;
Fehler, die es nicht gab. Die Schnittstelle antwortet ohnehin sie taeuscht nur Deckung vor.
live; eine neue Seite braucht es dafuer nicht. */
const nachher = await hinweise(); Was sie geprueft hat ("verschwindet von selbst, sobald ein Geraet
ok(!nachher.some((x) => x.art === "kein_geraet"), da ist"), prueft jetzt pruef-push-hinweis.mjs am neuen Kasten --
"mit einem angemeldeten Geraet ist der Hinweis weg — nichts zu quittieren"); dort mit einer echten Anmeldung, die nachweislich in der
} Datenbank landet. */
const eintrag = p.prepare("SELECT * FROM push_anmeldungen").get(); const eintrag = p.prepare("SELECT * FROM push_anmeldungen").get();
ok(eintrag?.person_id === idDogi && /^https:\/\//.test(eintrag?.endpunkt || ""), ok(eintrag?.person_id === idDogi && /^https:\/\//.test(eintrag?.endpunkt || ""),
+100 -8
View File
@@ -599,6 +599,28 @@ const ROLLEN = [
]; ];
const BREITEN = [[390, 844, "iPhone"], [412, 915, "Android"]]; const BREITEN = [[390, 844, "iPhone"], [412, 915, "Android"]];
/** Meldet sich auf der Team-Adresse an und wartet, bis die Startseite steht.
*
* EINE STELLE, ZWEI AUFRUFER (03.10.2026). Diese sechs Zeilen standen
* in der Schleife unten, und als ich sie fuer eine zweite Messung
* gebraucht habe, habe ich sie abgeschrieben -- und dabei eine
* aeltere Fassung erwischt: ohne `isVisible()` und mit `click()`
* statt `check()`. Ergebnis war ein Zeitablauf nach 30 Sekunden an
* der Altersfrage, die es fuer DogFather gar nicht gibt. Eine halbe
* Stunde fuer eine Abschrift.
*
* Die Altersfrage erscheint nur fuer die Community -- deshalb erst
* fragen, ob sie ueberhaupt sichtbar ist. */
async function anmeldenAufCrew(seite, rolle, code) {
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded", timeout: 20000 });
await seite.click(`.rolle[data-rolle="${rolle}"]`, { timeout: 8000 });
await seite.fill("#code", code);
const frage = await seite.$("#alter-ok");
if (frage && await frage.isVisible().catch(() => false)) await frage.check();
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 20000 });
}
let fehler = 0, geprueft = 0, elementeGesamt = 0, bedienbarGesamt = 0; let fehler = 0, geprueft = 0, elementeGesamt = 0, bedienbarGesamt = 0;
const probleme = []; const probleme = [];
const melde = (t) => console.log(t); const melde = (t) => console.log(t);
@@ -634,14 +656,7 @@ for (const [breite, hoehe, geraet] of BREITEN) {
/* ---- Anmelden, wie ein Mensch es tut ---------------------------- */ /* ---- Anmelden, wie ein Mensch es tut ---------------------------- */
let drin = false; let drin = false;
try { try {
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded", timeout: 20000 }); await anmeldenAufCrew(seite, rolle, code);
await seite.click(`.rolle[data-rolle="${rolle}"]`, { timeout: 8000 });
await seite.fill("#code", code);
/* Die Altersfrage erscheint nur für die Community. */
const frage = await seite.$("#alter-ok");
if (frage && await frage.isVisible().catch(() => false)) await frage.check();
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 20000 });
drin = true; drin = true;
} catch (f) { } catch (f) {
/* EINE GESCHEITERTE ANMELDUNG IST DER SCHLIMMSTE BEFUND -- danach /* EINE GESCHEITERTE ANMELDUNG IST DER SCHLIMMSTE BEFUND -- danach
@@ -864,9 +879,86 @@ melde("\n --- Gegenprobe (beide Fehler MÜSSEN gefunden werden) ---");
await kontext.close(); await kontext.close();
} }
/* =====================================================================
DIE "FUER WEN"-REIHE GIBT ES AUF crew. NICHT MEHR (03.10.2026)
Filipe, mit Bildschirmfoto der Leiste "FUER WEN · Alle 3 2 ·
Marina 1 · Diene 0": "brauchen wir nicht auf der aufgaben seite."
WARUM DAS HIER GEPRUEFT WIRD UND NICHT IM AUFGABENBRETT: Es ist
DIESELBE Reihe in beiden Haeusern, und sie bleibt bei Spicy Media
-- dort filtern Scouts und Manager durch ihre Creator. Die
Gegenprobe steht deshalb schon in pruef-aufgabenbrett ("Mara sieht
Alle, Tili, Luna"), und nur die crew-Seite fehlte. Ohne diese
Zeilen waere die Trennung eine Behauptung: Niemand wuerde merken,
wenn die Reihe auf crew. zurueckkaeme -- oder wenn sie der Agentur
nebenbei mit abhanden kaeme.
DIE GEGENPROBE STEHT DANEBEN, und sie ist hier das Wichtigste:
"Reihe nicht da" waere auch auf einer leeren oder kaputten Seite
wahr. Deshalb muss die Schwester-Reihe (Alle / Heute faellig /
Ueberfaellig) im selben Atemzug SICHTBAR sein. Erst beides zusammen
heisst "die Seite steht, und genau dieses eine Stueck fehlt".
===================================================================== */
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
ignoreHTTPSErrors: true,
});
const seite = await kontext.newPage();
let gemessen = null;
try {
await anmeldenAufCrew(seite, "admin", "CODE-DOGI-0001");
await seite.goto(BASIS + "/workspace/aufgaben.html",
{ waitUntil: "domcontentloaded", timeout: 20000 });
await seite.waitForTimeout(1200);
gemessen = await seite.evaluate(() => {
const r = document.getElementById("creator-reihe");
const a = document.getElementById("ausschnitte");
return {
reiheDa: !!r && r.hidden === false,
knoepfe: document.querySelectorAll(".creator-knopf").length,
schwesterDa: !!a && a.hidden === false,
schwesterKnoepfe: a ? a.querySelectorAll("button").length : 0,
};
});
} catch (f) {
melde(` FEHL "Fuer wen"-Reihe: nicht messbar -- ${String(f.message).slice(0, 160)}`);
probleme.push(`"Fuer wen"-Reihe: nicht messbar (${String(f.message).slice(0, 70)})`);
fehler++;
}
if (gemessen) {
geprueft += 3;
const g1 = gemessen.schwesterDa && gemessen.schwesterKnoepfe >= 2;
const g2 = !gemessen.reiheDa;
const g3 = gemessen.knoepfe === 0;
melde("");
melde(` ${g1 ? "ok " : "FEHL"} die Aufgabenseite steht wirklich `
+ `(${gemessen.schwesterKnoepfe} Knoepfe in der Ausschnitt-Reihe)`);
melde(` ${g2 ? "ok " : "FEHL"} und die "Fuer wen"-Reihe ist weg (sichtbar: ${gemessen.reiheDa})`);
melde(` ${g3 ? "ok " : "FEHL"} auch kein einzelner Knopf davon (${gemessen.knoepfe})`);
if (!g1) { fehler++; probleme.push('"Fuer wen": die Seite stand gar nicht -- die Messung beweist nichts'); }
if (!g2 || !g3) { fehler++; probleme.push('"Fuer wen"-Reihe steht auf crew. wieder da'); }
}
await kontext.close();
}
await browser.close(); await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
/* DIE BEFUNDE GEHOEREN UNTER DIE ZAHL (03.10.2026).
`probleme` wurde seit jeher gefuellt und NIE ausgegeben: Am Ende
stand "1 Befund" und sonst nichts. Wer das liest, weiss, DASS etwas
ist, aber nicht was -- und muss den ganzen Lauf noch einmal
anstossen, um es zu erfahren. Genau das ist mir heute passiert.
Ein Befund ohne Grund ist eine Fundstelle, die man ueberliest. */
if (probleme.length) {
melde("");
melde(" --- Befunde im Einzelnen ---");
for (const t of probleme) melde(` · ${t}`);
}
melde(""); melde("");
melde(` ${geprueft} Seitenaufrufe, ${elementeGesamt} Elemente, ${bedienbarGesamt} Bedienelemente gemessen`); melde(` ${geprueft} Seitenaufrufe, ${elementeGesamt} Elemente, ${bedienbarGesamt} Bedienelemente gemessen`);
melde(` ${fehler} Befund${fehler === 1 ? "" : "e"}`); melde(` ${fehler} Befund${fehler === 1 ? "" : "e"}`);
+231
View File
@@ -0,0 +1,231 @@
/* =====================================================================
DIE HAUSTRENNUNG BEI DEN EINTRAEGEN (03.10.2026)
Filipe, 24.09.2026: "wenn ich bei der einen was mache soll nichts bei
der anderen passieren. ... Kein Bestand wird geteilt."
WAS AM 03.10.2026 GEMESSEN WURDE (server/_mess-haeuser.mjs, jedes
Brett x beide Adressen x vier Rollen): Die Anmeldung war dicht --
eine Agenturrolle kam nicht auf die Crew-Adresse und umgekehrt.
Durch griff genau EINE Rolle: DogFather. Er wohnt in beiden
Haeusern, und die Riegel in sichtbarEintrag() fragten nach der
ROLLE, nicht nach der Adresse. Auf der Crew-Adresse stand damit das
Brett der Agentur samt Inhalt.
Behoben durch Riegel 0 in sichtbarEintrag(): Wer auf einer Adresse
angemeldet ist, sieht nur Eintraege dieses Hauses.
---------------------------------------------------------------------
WARUM ES DIESE DATEI BRAUCHT UND NICHT NUR DEN RIEGEL
Der Riegel haengt an der Spalte `eintraege.haus`. Die wird beim
ANLEGEN gesetzt -- und zwar von Hand, an jeder Stelle einzeln, die
einen Eintrag anlegt. Gemessen am 03.10.2026: FUENF von ACHT
Stellen taten es nicht (workspace-video.js, workspace-content.js,
workspace-bewerbung.js, workspace-treff.js, workspace-vorlagen.js).
Das ist dieselbe Falle wie die abgeschriebene Spaltenliste vom
11.09.2026: Eine Angabe, die an acht Stellen von Hand gepflegt
werden muss, ist an der neunten vergessen -- und zwar still.
Ein Kommentar daneben verhindert das nicht (auch das steht so im
Projektgedaechtnis: der Hinweis stand drei Tage lang direkt neben
der Stelle und wurde beim naechsten Mal trotzdem nicht gelesen).
Eine Pruefung schon. Diese hier wird rot, sobald eine neunte Stelle
dazukommt, die `haus` vergisst.
===================================================================== */
import { mkdtempSync, rmSync, appendFileSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-hl-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Haus-Luecken-Pruefung");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const PROTOKOLL = join(process.cwd(), "pruef-haus-luecke-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* =======================================================================
1. OHNE SERVER: Schreibt jede Stelle das Haus mit?
======================================================================= */
melde("=== 1. Jede Stelle, die einen Eintrag anlegt, setzt `haus` ===");
{
const dateien = readdirSync(".")
.filter((f) => f.startsWith("workspace") && f.endsWith(".js"));
/* DIE DATEIEN WERDEN GESUCHT, NICHT AUFGEZAEHLT. Eine Liste von
Dateinamen hier waere genau die Sorte Angabe, die beim naechsten
neuen Modul veraltet -- und dann prueft diese Datei die neue
Stelle nicht, ohne dass etwas rot wird. */
ok(dateien.length > 10, `${dateien.length} Servermodule durchsucht`);
const stellen = [];
for (const datei of dateien) {
const text = readFileSync(datei, "utf8");
/* Jede Einfuegung in `eintraege` samt ihrer Spaltenliste. Die
Spaltenliste endet an der ersten schliessenden Klammer vor
VALUES. */
const muster = /INSERT\s+INTO\s+eintraege\s*\(([^)]*)\)/gi;
let t;
while ((t = muster.exec(text)) !== null) {
const zeile = text.slice(0, t.index).split("\n").length;
const spalten = t[1].split(",").map((x) => x.trim());
stellen.push({ datei, zeile, hatHaus: spalten.includes("haus") });
}
}
ok(stellen.length >= 8, `${stellen.length} Einfuege-Stellen gefunden (erwartet mindestens 8)`);
const ohne = stellen.filter((s) => !s.hatHaus);
for (const s of stellen) {
melde(` -- ${s.datei}:${s.zeile} ${s.hatHaus ? "setzt haus" : "OHNE haus"}`);
}
ok(ohne.length === 0, ohne.length
? `diese Stellen vergessen das Haus: ${ohne.map((s) => s.datei + ":" + s.zeile).join(", ")}`
: "alle setzen es");
/* GEGENPROBE: Findet dieses Muster ueberhaupt etwas, das OHNE haus
ist? Sonst koennte es an einem Tippfehler im regulaeren Ausdruck
liegen, und die Pruefung waere dauerhaft gruen, ohne je etwas
gesehen zu haben. */
const probe = "INSERT INTO eintraege (bereich, art, titel)\nVALUES (?,?,?)";
const m = /INSERT\s+INTO\s+eintraege\s*\(([^)]*)\)/i.exec(probe);
ok(!!m && !m[1].split(",").map((x) => x.trim()).includes("haus"),
"das Suchmuster erkennt eine Stelle OHNE haus (Gegenprobe)");
const probe2 = "INSERT INTO eintraege (bereich, art, haus)\nVALUES (?,?,?)";
const m2 = /INSERT\s+INTO\s+eintraege\s*\(([^)]*)\)/i.exec(probe2);
ok(!!m2 && m2[1].split(",").map((x) => x.trim()).includes("haus"),
"und eine MIT haus (Gegenprobe andersherum)");
}
/* =======================================================================
2. MIT SERVER: Greift der Riegel wirklich?
======================================================================= */
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(240_000, "pruef-haus-luecke");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:" + PORT;
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Dogfather", "admin", "CODE-DOGI-0001");
const heute = new Date().toISOString().slice(0, 10);
/* Drei Marken auf demselben Brett: eine je Haus und eine ohne. */
for (const [titel, haus] of [["Marke-CREW", "crew"], ["Marke-AGENTUR", "agentur"], ["Marke-OHNE", null]]) {
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, dringlichkeit, erstellt, erstellt_von, haus)"
+ " VALUES (?,?,'anschlag','ansage','offen',?, 'mittel', ?, 1, ?)")
.run(titel, "Test.", heute, jetzt, haus);
}
d.close();
const { request } = await import("node:http");
function roh(host, pfad, o = {}) {
return new Promise((f, s) => {
const daten = o.rumpf === undefined ? null : Buffer.from(JSON.stringify(o.rumpf));
const a = request({
host: "127.0.0.1", port: PORT, path: pfad,
method: o.methode || (daten ? "POST" : "GET"),
headers: {
Host: host, Origin: "https://" + host,
...(o.keks ? { Cookie: o.keks } : {}),
...(daten ? { "Content-Length": daten.length, "Content-Type": "application/json" } : {}),
},
}, (r) => {
const t = [];
r.on("data", (x) => t.push(x));
r.on("end", () => f({ code: r.statusCode, kopf: r.headers, text: Buffer.concat(t).toString("utf8") }));
});
a.on("error", s);
if (daten) a.write(daten);
a.end();
});
}
const CREW = "crew.dogfather-universe.com";
const AGEN = "workspace.dogfather-universe.com";
async function titelAuf(host) {
const anm = await roh(host, "/workspace/api/anmelden", { rumpf: { rolle: "admin", code: "CODE-DOGI-0001" } });
if (anm.code !== 200) return null;
const keks = [].concat(anm.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
const a = await roh(host, "/workspace/api/bereich/anschlag", { keks });
if (a.code !== 200) return null;
try { return (JSON.parse(a.text).eintraege || []).map((e) => e.titel).sort(); } catch { return null; }
}
melde("\n=== 2. DogFather sieht nur das Haus seiner Adresse ===");
{
const aufCrew = await titelAuf(CREW);
const aufAgentur = await titelAuf(AGEN);
/* DER DRITTE AUSGANG: Antwortet der Server gar nicht, ist das kein
"alles sauber" -- dann konnte hier nichts gemessen werden. */
if (!aufCrew || !aufAgentur) {
melde("ABBRUCH: das Brett antwortet nicht -- hier wurde nichts geprueft.");
process.exit(3);
}
melde(" -- auf crew.: " + JSON.stringify(aufCrew));
melde(" -- auf workspace.: " + JSON.stringify(aufAgentur));
ok(aufCrew.includes("Marke-CREW"), "auf crew. steht die Crew-Marke");
ok(!aufCrew.includes("Marke-AGENTUR"), "auf crew. steht die Agentur-Marke NICHT");
ok(aufAgentur.includes("Marke-AGENTUR"), "auf workspace. steht die Agentur-Marke");
ok(!aufAgentur.includes("Marke-CREW"), "auf workspace. steht die Crew-Marke NICHT");
/* DER RUECKFALL IST ABSICHT UND WIRD ALS SOLCHER GEPRUEFT: Ein
Eintrag ohne Haus bleibt in beiden sichtbar. Das ist die sichere
Richtung -- ein Eintrag zu viel faellt auf, ein fehlender nicht.
Teil 1 sorgt dafuer, dass dieser Fall gar nicht erst entsteht. */
ok(aufCrew.includes("Marke-OHNE") && aufAgentur.includes("Marke-OHNE"),
"ein Eintrag OHNE Haus bleibt in beiden sichtbar (bewusster Rueckfall)");
/* GEGENPROBE ZUR MESSUNG SELBST: Wenn hier nur eine Marke stuende,
waeren drei der Zusagen oben gruen, ohne etwas zu beweisen. */
ok(aufCrew.length === 2 && aufAgentur.length === 2,
`je zwei von drei Marken sind sichtbar (${aufCrew.length} / ${aufAgentur.length})`);
}
melde("\n=== 3. Ohne Adresse wird nicht gefiltert ===");
{
/* Auf 127.0.0.1 gibt es kein Haus -- dort muss alles sichtbar
bleiben, sonst waeren alle Pruefungen des Hauses still gruen und
blind. Genau davor warnt crew-adresse.js dreimal. */
const alles = await titelAuf("127.0.0.1:" + PORT);
ok(alles !== null && alles.length === 3,
`auf einer Pruefadresse stehen alle drei Marken (${alles ? alles.length : "keine Antwort"})`);
}
melde("");
melde(`${geprueft} geprueft, ${fehler} Fehler`);
if (!geprueft) { melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); process.exit(3); }
process.exit(fehler ? 1 : 0);
+93
View File
@@ -996,6 +996,99 @@ console.log("=== Haengt es wirklich an der Adresse? ===");
+ `(${titel(fremd) || "leer"} / ${titel(echt) || "leer"})`); + `(${titel(fremd) || "leer"} / ${titel(echt) || "leer"})`);
} }
/* =======================================================================
DIE BRETTER DES RUDELS GIBT ES NUR AUF DER CREW-ADRESSE (05.10.2026)
Filipe: "ich kann gerade bei highlights keine videos hochladen."
GEMESSEN AN DER LAUFENDEN DATENBANK, bevor etwas angefasst wurde:
49 Highlights, ALLE mit `haus = crew` -- so legt
`hausFuerNeuenEintrag` sie an, ausdruecklich unabhaengig von der
Adresse. Der Adressriegel vom 03.10. zeigt auf der Agenturadresse
aber nur `haus = agentur`:
crew. -> 49 Highlights
workspace -> 0
Das Brett stand dort leer, und ein eingefuegtes Video verschwand in
derselben Sekunde -- angelegt und sofort ausgeblendet. Von aussen
sieht das aus wie "das Hochladen geht nicht". Belegt: Eintrag #77
wurde heute um 08:41 erfolgreich angelegt.
ZWEI REGELN WIDERSPRACHEN SICH, und keine Pruefung konnte das
merken: Jede fuer sich war richtig. Diese hier misst genau den
Zwischenraum -- deshalb in BEIDE Richtungen und auf allen drei
Wegen (Kachel, Brett, Video). Ein Riegel an nur einem davon waere
derselbe Fehler in leiser.
======================================================================= */
console.log("");
console.log("=== Rudel-Bretter: nur auf crew. ===");
{
const a = await beide("/workspace/api/ich", dogi.keks);
const ziele = (x) => [...(x?.bereiche || []), ...(x?.bereiche_zusatz || [])]
.map((k) => k.ziel || "");
const crewZiele = ziele(a.crew);
const wsZiele = ziele(a.ws);
/* Die Zahl gehoert in die Bedingung: Kaeme gar keine Kachel zurueck,
waere "keine Highlight-Kachel" gruen, ohne etwas gesehen zu haben. */
ok(crewZiele.length > 0 && crewZiele.some((z) => /b=highlight/.test(z)),
`auf crew. steht die Highlights-Kachel (${crewZiele.length} Kacheln)`);
/* AUF DER AGENTURADRESSE SCHICKT DER SERVER GAR KEINE LISTE, und das
ist Absicht: `bereiche === null` heisst "der Browser nimmt seine
eigene". Hier stand zuerst eine Zeile, die dort Agenturbretter
erwartete -- sie wurde rot, und zwar zu Recht: Ich hatte eine
Liste vermutet, wo keine ist. In der Browserliste stehen die
Rudel-Bretter ohnehin nicht (weder in start.html noch in
bereiche.js nachweisbar), deshalb gibt es dort auch nichts
auszublenden -- die Kachel war nie das Problem. */
ok(a.ws?.bereiche === null,
`auf der Agenturadresse kommt bewusst keine Serverliste (${JSON.stringify(a.ws?.bereiche)})`);
ok(!wsZiele.some((z) => /b=highlight/.test(z)),
`und nirgends eine Highlights-Kachel (${wsZiele.length} Zusatzkacheln geprueft)`);
/* ---- Das Brett selbst ------------------------------------------- */
const hl = {
crew: await hole("/workspace/api/bereich/highlight", { host: CREW, keks: dogi.keks }),
ws: await hole("/workspace/api/bereich/highlight", { host: WS, keks: dogi.keks }),
};
ok(hl.crew.status === 200, `das Brett antwortet auf crew. (${hl.crew.status})`);
ok(hl.ws.status === 404,
`auf der Agenturadresse gibt es es gar nicht -- 404 statt leer (${hl.ws.status})`);
/* ---- Und der Weg, auf dem das Video hereinkommt ------------------
Er haengt NICHT an derselben Middleware und braucht seinen
eigenen Riegel. Genau deshalb wird er hier eigens gemessen: Ohne
ihn liesse sich weiterhin etwas anlegen, das danach niemand
sieht -- derselbe Zustand, nur still. */
const video = await hole("/workspace/api/bereich/highlight/video", {
host: WS, keks: dogi.keks, methode: "POST",
rumpf: JSON.stringify({ url: "https://www.tiktok.com/@dogfather/video/1234567890" }),
});
ok(video.status === 404,
`auch ein Video laesst sich von dort nicht mehr einfuegen (${video.status})`);
/* GEGENPROBE: Auf crew. wird es NICHT mit 404 abgewiesen. Die
Adresse ist der Unterschied, nicht etwa eine kaputte Route --
sonst waere die Zeile darueber wertlos. (Dass TikTok die erfundene
Nummer nicht kennt, ist hier egal: Jede Antwort ausser 404 zeigt,
dass der Riegel nicht gegriffen hat.) */
const videoCrew = await hole("/workspace/api/bereich/highlight/video", {
host: CREW, keks: dogi.keks, methode: "POST",
/* ABSICHTLICH KEINE ECHTE TIKTOK-ADRESSE. Der Riegel sitzt VOR der
Adresspruefung: Kommt hier 400 ("keine TikTok-Adresse") zurueck,
ist er nachweislich nicht gesprungen -- und die Pruefung schickt
dabei keine Anfrage ins Netz. Mit einer echten Adresse lief der
Server gegen TikTok, und die offenen Verbindungen rissen ihn
beim Beenden in einen libuv-Absturz: 107 Pruefungen, 1 Fehler,
Rueckgabewert 127. */
rumpf: JSON.stringify({ url: "https://example.invalid/kein-tiktok" }),
});
ok(videoCrew.status === 400,
`Gegenprobe: auf crew. greift der Riegel nicht (${videoCrew.status} statt 404)`);
}
d.close(); d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(""); console.log("");
+99
View File
@@ -945,6 +945,105 @@ console.log("");
ok(danach.markiert >= 1, ok(danach.markiert >= 1,
"der Katalog markiert sie als uebernommen (" + danach.markiert + ")"); "der Katalog markiert sie als uebernommen (" + danach.markiert + ")");
/* ==== ANPASSEN AM KATALOG -- DORT, WO VANVAN ARBEITET =========
==============================================================
VanVan im Support, Meldung #6: „Es waere gut, wenn man auch
dort die Moeglichkeit haette die Frist anzupassen, sie als
dauerhafte Aufgabe einstellen zu koennen oder eventuell noch
eine Anmerkung hinzufuegen."
WARUM DIESE ZEILEN HIER STEHEN UND NICHT NUR IN
pruef-aufgaben-vorlagen: Dort wird der CREATOR-Zweig gemessen
-- das Vorlagenbrett auf „Aufgaben", angemeldet als Creator.
VanVan benutzt den KATALOG auf „Eure Aufgaben". Das sind zwei
verschiedene Karten in derselben Datei, und der Knopf haengt
an beiden einzeln.
Nach dem Einbau war nur der eine Zweig im Browser geprueft.
Beide liefern denselben Haken NICHT: Ein Creator darf
„dauerhaft" nicht setzen, die Leitung schon -- und genau
dieser Unterschied ist hier die Gegenprobe zu dort.
Dass man sich bei diesen beiden Brettern vertun kann, ist in
diesem Haus schon vorgekommen: In pruef-bewerbung-aufgaben
steht es als Messfehler notiert, „der wie ein Befund
aussieht". */
const anpassbar = await seite.evaluate(() => ({
karten: document.querySelectorAll(".vorlagenbrett__karte").length,
knoepfe: document.querySelectorAll(".vorlagenbrett__anpassen").length,
}));
ok(anpassbar.karten > 0 && anpassbar.knoepfe === anpassbar.karten,
`jede Katalogkarte hat einen „Anpassen …"-Knopf `
+ `(${anpassbar.knoepfe} von ${anpassbar.karten})`);
await seite.click(".vorlagenbrett__karte .vorlagenbrett__anpassen");
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 8000 });
const felder = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.nachfrage");
const sicht = (teil) => {
const z = dlg.querySelector(`[data-teil="${teil}"]`);
return !!z && !z.hidden;
};
return {
datum: sicht("datumzeile"),
haken: sicht("hakenzeile"),
grund: sicht("grundzeile"),
wert: dlg.querySelector("#nachfrage-datum")?.value || "",
min: dlg.querySelector("#nachfrage-datum")?.min || "",
};
});
ok(felder.datum && felder.grund,
`das Fenster hat Frist und Anmerkung (${felder.datum}, ${felder.grund})`);
/* DIE GEGENPROBE ZU pruef-aufgaben-vorlagen: Dort bekommt ein
Creator den Haken NICHT. Hier, bei der Leitung, MUSS er da
sein -- sonst waere „dauerhaft" eine Einstellung, die es nur
im Quelltext gibt. */
ok(felder.haken === true,
`und die Leitung bekommt den „dauerhaft"-Haken (${felder.haken})`);
ok(/^\d{4}-\d{2}-\d{2}$/.test(felder.wert) && felder.wert >= felder.min,
`die Frist der Vorlage steht als Vorgabe drin (${felder.wert}, `
+ `fruehestens ${felder.min})`);
/* DER HAKEN SPERRT DAS DATUM -- eine dauerhafte Aufgabe hat
keine Frist. Ein Datum, das dasteht und nicht gilt, ist
schlimmer als keins. */
await seite.check("dialog.nachfrage #nachfrage-haken");
await seite.waitForTimeout(200);
ok((await seite.evaluate(() =>
document.querySelector("dialog.nachfrage #nachfrage-datum").disabled)) === true,
"mit Haken wird die Frist gesperrt");
const vorDauer = await seite.evaluate(zaehleListe);
await seite.fill("dialog.nachfrage #nachfrage-grund",
"Laeuft dauerhaft mit, bitte woechentlich kurz durchgehen.");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(2600);
ok(await seite.evaluate(zaehleListe) === vorDauer + 1,
`die angepasste Aufgabe liegt in ihrer Liste `
+ `(${vorDauer} -> ${await seite.evaluate(zaehleListe)})`);
/* UND JETZT DIE TATSACHEN, nicht die Anzeige. Was auf dem Brett
steht, ist eine Darstellung; was in der Aufgabe steht, gilt. */
{
const { DatabaseSync: DB5 } = await import("node:sqlite");
const d5 = new DB5(process.env.WORKSPACE_DB);
const neueste = d5.prepare(`SELECT a.id, a.dauerhaft, a.frist,
(SELECT COUNT(*) FROM aufgaben_notizen n WHERE n.aufgabe_id = a.id) AS notizen,
(SELECT text FROM aufgaben_notizen n WHERE n.aufgabe_id = a.id LIMIT 1) AS wort
FROM aufgaben a ORDER BY a.id DESC LIMIT 1`).get();
d5.close();
ok(neueste?.dauerhaft === 1,
`sie ist wirklich dauerhaft (${neueste?.dauerhaft})`);
/* KEINE FRIST -- sonst waere sie ab morgen fuer immer
ueberfaellig, und eine Warnung, die immer kommt, ist keine
mehr. Dieselbe Regel wie im Aenderungsweg. */
ok(!neueste?.frist,
`und hat keine Frist (${neueste?.frist ?? "keine"})`);
ok(neueste?.notizen === 1 && /woechentlich/.test(neueste?.wort || ""),
`die Anmerkung steht als Notiz dabei („${neueste?.wort}")`);
}
/* ==== DIE BEWERBUNG AM BILDSCHIRM ENTSCHEIDEN (23.09.2026) ====== /* ==== DIE BEWERBUNG AM BILDSCHIRM ENTSCHEIDEN (23.09.2026) ======
Filipe: „nur dogfather und die rechte hand sollen annehmen oder Filipe: „nur dogfather und die rechte hand sollen annehmen oder
+94
View File
@@ -702,6 +702,100 @@ console.log("\n=== 8. Aufgaben, Einträge und Dateien eines Modis ===\n");
} }
} }
/* ---------- 8b. Was nach zwei Fehlversuchen dasteht -------------------
=====================================================================
VanVan im Support, Meldung #5, zweite Runde: „Man kann sich jetzt
nur noch über die richtige Schaltfläche anmelden. Das funktioniert
jetzt. Aber wenn man sich 2x versucht über den falschen Button
anzumelden, dann kommt ein irreführender Text wo steht, dass die
Auswahl oben nicht schuld ist."
DER SATZ WAR EINMAL RICHTIG. Auf der Crew-Wand gab es den stillen
Zugang; dort entschied die Kachel wirklich nicht, und wer alle
durchprobierte, sperrte sich nur selbst aus. Mit Abschnitt 1 oben
ist dieser Weg weggefallen -- und ab da sagte der Satz auf der
Wand, auf der jemand gerade feststeckt, das Gegenteil der Wahrheit.
GEPRUEFT WIRD BEIDES ZUSAMMEN: dass die Kachel wirklich entscheidet
(Abschnitt 1) UND dass der Satz das sagt. Eines ohne das andere
waere wieder die Lage von gestern.
DIE SPERRE WIRD VORHER GELEERT. Acht Fehlversuche je Adresse in
zehn Minuten -- die Abschnitte davor verbrauchen welche, und ab
dem neunten steht „Zu viele Versuche" statt des Satzes, um den es
hier geht. Das waere ein roter Haken ueber die Messumgebung, nicht
ueber das Programm. Gemessen werden soll der Hinweis, nicht die
Bremse.
-------------------------------------------------------------------- */
console.log("\n=== 8b. Der Hinweis nach zwei Fehlversuchen ===\n");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const satzVon = async (wand) => {
/* Die Sperre je Lauf zuruecksetzen -- siehe oben. */
const dx = new DatabaseSync(process.env.WORKSPACE_DB);
dx.exec("DELETE FROM versuche");
dx.close();
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const seite = await kontext.newPage();
const konsole = [];
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
await seite.goto(`${BASIS}/workspace/${wand}`, { waitUntil: "domcontentloaded" });
await seite.waitForTimeout(400);
/* Irgendeine Kachel und ein Code, der zu keiner gehoert. */
await seite.click(".rolle >> nth=0");
const raus = [];
for (const n of [1, 2]) {
await seite.fill("#code", `FALSCH-CODE-000${n}`);
await seite.click("button[type=submit]");
await seite.waitForTimeout(900);
raus.push((await seite.textContent("#fehler") || "").replace(/\s+/g, " ").trim());
}
const crewKlasse = await seite.evaluate(() =>
document.body.classList.contains("gate--crew"));
await kontext.close();
return { nach1: raus[0], nach2: raus[1], crewKlasse, konsole };
};
const crew = await satzVon("crew-index.html");
const agentur = await satzVon("index.html");
ok(crew.crewKlasse === true && agentur.crewKlasse === false,
`zwei verschiedene Waende gemessen (crew ${crew.crewKlasse}, `
+ `agentur ${agentur.crewKlasse})`);
/* DER EIGENTLICHE BEFUND: Der alte Satz ist weg. */
ok(!/nicht schuld/i.test(crew.nach2),
`auf der Crew-Wand steht nicht mehr „die Auswahl oben ist nicht schuld" `
+ `(„${crew.nach2}")`);
ok(/Auswahl oben/.test(crew.nach2),
"sondern die Frage nach der Auswahl -- die dort seit Abschnitt 1 wirklich gilt");
/* EIN SATZ FUER BEIDE WAENDE. Zwei verschiedene waeren wieder zwei
Antworten auf dieselbe Frage, und eine davon veraltet. */
ok(crew.nach2 === agentur.nach2,
`und es ist derselbe Satz wie auf der Agenturwand (${crew.nach2 === agentur.nach2})`);
/* GEGENPROBE: Nach dem ERSTEN Fehlversuch steht der Hinweis noch
NICHT da. Ohne sie waere „der Hinweis kommt" auch dann gruen,
wenn er immer kaeme -- und ein Hinweis, der immer kommt, ist
keiner. */
ok(!/Auswahl oben/.test(crew.nach1),
`Gegenprobe: nach dem ersten Versuch steht er noch nicht da („${crew.nach1}")`);
ok(crew.nach1.length > 0 && /stimmt nicht/i.test(crew.nach1),
` dort steht die kurze Absage („${crew.nach1}")`);
ok(crew.konsole.length === 0 && agentur.konsole.length === 0,
`kein Skriptfehler auf beiden Waenden (${crew.konsole[0] || agentur.konsole[0] || "keiner"})`);
} finally {
await browser.close();
}
}
/* ---------- 9. Die Gegenprobe ---------------------------------------- /* ---------- 9. Die Gegenprobe ----------------------------------------
Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person
absichtlich aus der Regel aus. Lief sie weiter oben, tauchte absichtlich aus der Regel aus. Lief sie weiter oben, tauchte
+220 -9
View File
@@ -756,12 +756,17 @@ if (process.env.OHNE_BROWSER === "ja") {
}); });
await seite.waitForTimeout(400); await seite.waitForTimeout(400);
const gewaehlt = await seite.evaluate(() => { const gewaehlt = await seite.evaluate(() => {
const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]'); /* EIN KASTEN MIT EINER LISTE statt eines einzelnen <img>
(02.10.2026): Seit VanVans Meldung #11 koennen es bis zu
drei sein, und `bildMax` entscheidet, wie viele. */
const kasten = document.querySelector('dialog.nachfrage [data-teil="bildvorschauen"]');
const schau = kasten?.querySelector("img") || null;
return { return {
name: document.querySelector('dialog.nachfrage [data-teil="bildname"]') name: document.querySelector('dialog.nachfrage [data-teil="bildname"]')
?.textContent || "", ?.textContent || "",
wegDa: document.querySelector('dialog.nachfrage [data-teil="bildweg"]')?.hidden === false, wegDa: document.querySelector('dialog.nachfrage [data-teil="bildweg"]')?.hidden === false,
vorschau: !!schau && !schau.hidden && (schau.getAttribute("src") || "").length > 0, vorschau: !!kasten && !kasten.hidden && !!schau
&& (schau.getAttribute("src") || "").length > 0,
hoch: schau ? Math.round(schau.getBoundingClientRect().height) : 0, hoch: schau ? Math.round(schau.getBoundingClientRect().height) : 0,
}; };
}); });
@@ -796,7 +801,7 @@ if (process.env.OHNE_BROWSER === "ja") {
feld.files = dt.files; feld.files = dt.files;
feld.dispatchEvent(new Event("change", { bubbles: true })); feld.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
const schau = document.querySelector('dialog.nachfrage [data-teil="bildvorschau"]'); const schau = document.querySelector('dialog.nachfrage .nachfrage__vorschau img');
const dlg = document.querySelector("dialog.nachfrage"); const dlg = document.querySelector("dialog.nachfrage");
const ja = document.querySelector('dialog.nachfrage [data-teil="ja"]'); const ja = document.querySelector('dialog.nachfrage [data-teil="ja"]');
const r = schau.getBoundingClientRect(); const r = schau.getBoundingClientRect();
@@ -813,9 +818,14 @@ if (process.env.OHNE_BROWSER === "ja") {
dialogHoch: Math.round(dlg.getBoundingClientRect().height), dialogHoch: Math.round(dlg.getBoundingClientRect().height),
}; };
}); });
/* 120 STATT 160 SEIT DEM 02.10.2026. Der Deckel ist gesunken,
weil in dem Kasten jetzt bis zu drei Bilder nebeneinander
stehen koennen -- bei 160 waere der Senden-Knopf bei drei
hochkanten Bildern aus dem Fenster. Die Zahl steht in
module.css und wird hier nachgemessen, nicht geraten. */
pruef(` ein 300x900 grosses Bild wird auf ${hoch.gezeigt} px gedeckelt` pruef(` ein 300x900 grosses Bild wird auf ${hoch.gezeigt} px gedeckelt`
+ ` (echt ${hoch.echt}, höchstens 160)`, + ` (echt ${hoch.echt}, höchstens 120)`,
hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 160); hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 120);
pruef(` und es bleibt in der Breite (${hoch.breit} px)`, hoch.breit <= 300); pruef(` und es bleibt in der Breite (${hoch.breit} px)`, hoch.breit <= 300);
pruef(` der Senden-Knopf steht weiter im Fenster (Dialog ${hoch.dialogHoch} px)`, pruef(` der Senden-Knopf steht weiter im Fenster (Dialog ${hoch.dialogHoch} px)`,
hoch.jaImFenster); hoch.jaImFenster);
@@ -825,15 +835,23 @@ if (process.env.OHNE_BROWSER === "ja") {
await seite.waitForTimeout(500); await seite.waitForTimeout(500);
const erg = await seite.evaluate(async () => { const erg = await seite.evaluate(async () => {
const a = await window.__probe; const a = await window.__probe;
/* `bilder` UND NICHT `bild` (02.10.2026): Der Dialog gibt
immer eine Liste zurueck, auch wenn nur eines erlaubt war.
Ein Sonderfall „genau eines" waere die zweite Auskunft auf
dieselbe Frage -- und die veraltet. */
const eins = a && Array.isArray(a.bilder) ? a.bilder[0] : null;
return { return {
grund: a && a.grund, grund: a && a.grund,
bildName: a && a.bild ? a.bild.name : null, anzahl: a && Array.isArray(a.bilder) ? a.bilder.length : -1,
bildTyp: a && a.bild ? a.bild.type : null, bildName: eins ? eins.name : null,
istDatei: !!(a && a.bild && typeof a.bild.size === "number"), bildTyp: eins ? eins.type : null,
istDatei: !!(eins && typeof eins.size === "number"),
}; };
}); });
pruef(`der Dialog gibt den Grund zurück („${erg.grund}")`, pruef(`der Dialog gibt den Grund zurück („${erg.grund}")`,
/siehe Bild/.test(erg.grund || "")); /siehe Bild/.test(erg.grund || ""));
pruef(` er gibt eine LISTE zurueck, nicht ein Feld (${erg.anzahl})`,
erg.anzahl === 1);
pruef(` und die Datei dazu (${erg.bildName}, ${erg.bildTyp})`, pruef(` und die Datei dazu (${erg.bildName}, ${erg.bildTyp})`,
/* und nicht : Zwei Zeilen /* und nicht : Zwei Zeilen
weiter oben wurde das Bild ERSETZT. Meine erste Fassung weiter oben wurde das Bild ERSETZT. Meine erste Fassung
@@ -875,9 +893,202 @@ if (process.env.OHNE_BROWSER === "ja") {
/Noch keins/.test(frisch)); /Noch keins/.test(frisch));
await seite.click('dialog.nachfrage [data-teil="nein"]'); await seite.click('dialog.nachfrage [data-teil="nein"]');
await seite.waitForTimeout(300); await seite.waitForTimeout(300);
/* ==== UND MIT `bildMax` SIND ES MEHRERE (02.10.2026) =========
VanVan im Support, Meldung #11: „2-3 wären besser." Der
Dialog kann das seither; ohne Angabe bleibt es bei einem,
damit sich fuer jeden anderen Aufruf im Haus nichts aendert.
DER UNTERSCHIED ZUM FALL DARUEBER IST DER PUNKT: Dort hat die
zweite Wahl die erste ERSETZT (ein Platz, eine Korrektur),
hier muss sie DAZUKOMMEN. Beides in derselben Pruefung, sonst
beweist keines etwas. */
await seite.evaluate(() => {
window.__probe4 = window.frageNach({
titel: "Mit dreien", ja: "Senden",
grund: "Was geht noch nicht?", grundPflicht: true, grundMin: 3,
bild: "Bildschirmfotos dazu (freiwillig)", bildMax: 3,
});
});
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 });
const zaehl = () => seite.evaluate(() =>
document.querySelectorAll("dialog.nachfrage .nachfrage__vorschau").length);
for (const name of ["a.png", "b.png", "c.png", "d.png"]) {
await seite.setInputFiles("dialog.nachfrage #nachfrage-bild", {
name, mimeType: "image/png", buffer: PNG_PROBE,
});
await seite.waitForTimeout(250);
}
const dreiDa = await zaehl();
pruef(`mit bildMax 3 legt jede Wahl DAZU (${dreiDa} nach vier Griffen)`,
dreiDa === 3);
const grenzWort = await seite.evaluate(() =>
document.querySelector('dialog.nachfrage [data-teil="fehler"]')?.textContent || "");
pruef(` und das vierte wird mit einem Satz abgelehnt („${grenzWort}")`,
/3 Bilder/.test(grenzWort));
/* EINZELN WEGNEHMEN, nicht alles. */
await seite.click("dialog.nachfrage .nachfrage__vorschau:nth-child(2) "
+ ".nachfrage__vorschau-weg");
await seite.waitForTimeout(200);
pruef(` eines einzeln entfernen laesst die anderen stehen (${await zaehl()})`,
await zaehl() === 2);
await seite.fill("dialog.nachfrage #nachfrage-grund", "Hier zwei Bilder dazu.");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(400);
const viele = await seite.evaluate(async () => {
const a = await window.__probe4;
return (a && Array.isArray(a.bilder)) ? a.bilder.map((b) => b.name) : null;
});
pruef(` und der Dialog gibt beide zurueck (${(viele || []).join(", ")})`,
Array.isArray(viele) && viele.length === 2
&& viele[0] === "a.png" && viele[1] === "c.png");
/* ==== EIN DATUM UND EIN HAKEN (02.10.2026) ==================
VanVan im Support, Meldung #6: „Bei den Vorlagen laesst sich
weder die Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch." Die Anmerkung gab es hier schon
(`grund`), die zwei anderen Felder nicht.
SIE STEHEN IM GEMEINSAMEN FENSTER und nicht in
vorlagenbrett.js -- sonst waere das der zweite Dialog im Haus,
in dem Esc, Fokus und Abbruch von Hand nachgebaut werden
muessten. */
await seite.evaluate(() => {
window.__probe5 = window.frageNach({
titel: "Anpassen", ja: "Übernehmen",
datum: "Frist", datumWert: "2030-05-17", datumMin: "2026-10-01",
haken: "Dauerhafte Aufgabe", hakenHinweis: "Sie wird nicht abgehakt.",
hakenSperrtDatum: true,
grund: "Anmerkung (freiwillig)",
});
});
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 6000 });
const dh = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.nachfrage");
const dfeld = dlg.querySelector("#nachfrage-datum");
const hfeld = dlg.querySelector("#nachfrage-haken");
const etikett = dlg.querySelector(".nachfrage__haken");
return {
datumDa: !dlg.querySelector('[data-teil="datumzeile"]').hidden,
wert: dfeld.value, min: dfeld.min,
hakenDa: !dlg.querySelector('[data-teil="hakenzeile"]').hidden,
hakenAn: hfeld.checked,
hinweis: dlg.querySelector('[data-teil="hakenhinweis"]').textContent,
/* 44 Pixel, wie jeder Knopf im Haus -- und die Flaeche kommt
vom Etikett, nicht vom Kaestchen. */
etikettHoch: Math.round(etikett.getBoundingClientRect().height),
kaestchen: Math.round(hfeld.getBoundingClientRect().width),
};
});
pruef(`das Datumsfeld steht da, mit Vorgabe („${dh.wert}")`,
dh.datumDa && dh.wert === "2030-05-17");
pruef(` und mit einer Untergrenze (${dh.min})`, dh.min === "2026-10-01");
pruef(`der Haken steht da und ist aus (${dh.hakenAn})`,
dh.hakenDa && dh.hakenAn === false);
pruef(` sein Etikett ist ${dh.etikettHoch} px hoch (Fingermass 44)`,
dh.etikettHoch >= 44);
/* EIN ANKREUZFELD UEBER DIE GANZE ZEILE sieht aus wie ein leeres
Textfeld -- die Regel `.nachfrage__form input { width: 100% }`
haette genau das gemacht. */
pruef(` und das Kaestchen ist ${dh.kaestchen} px breit, nicht die ganze Zeile`,
dh.kaestchen > 10 && dh.kaestchen < 60);
pruef(` der Hinweis steht dabei („${dh.hinweis}")`, /nicht abgehakt/.test(dh.hinweis));
/* DER HAKEN SPERRT DAS DATUM. Eine dauerhafte Aufgabe hat keine
Frist -- ein Datum, das dasteht und nicht gilt, ist schlimmer
als keins. */
await seite.check("dialog.nachfrage #nachfrage-haken");
await seite.waitForTimeout(150);
const gesperrt = await seite.evaluate(() => {
const dlg = document.querySelector("dialog.nachfrage");
return {
aus: dlg.querySelector("#nachfrage-datum").disabled,
blass: parseFloat(getComputedStyle(
dlg.querySelector('[data-teil="datumzeile"]')).opacity) < 1,
};
});
pruef(`mit Haken wird das Datum gesperrt (${gesperrt.aus})`, gesperrt.aus === true);
pruef(" und sieht auch gesperrt aus", gesperrt.blass === true);
/* GEGENPROBE: Haken wieder weg -> das Datum geht wieder. Ohne sie
koennte die Sperre dauerhaft sein und die Zeile oben waere
trotzdem gruen. */
await seite.uncheck("dialog.nachfrage #nachfrage-haken");
await seite.waitForTimeout(150);
pruef("Gegenprobe: ohne Haken geht das Datum wieder",
(await seite.evaluate(() =>
document.querySelector("dialog.nachfrage #nachfrage-datum").disabled)) === false);
/* ==== EIN DATUM VOR DER UNTERGRENZE -- ZWEI SCHRANKEN =======
Die erste ist das `min` am Feld: Der Datumswaehler bietet
fruehere Tage gar nicht erst an, und der Browser laesst das
Formular nicht abschicken. Das ist die freundlichere -- sie
greift, bevor jemand etwas tippt.
GEMESSEN WIRD, DASS SIE WIRKLICH GREIFT, nicht dass sie
dasteht: `rangeUnderflow` sagt es, das Attribut allein nicht.
Mein erster Anlauf erwartete hier den HAUSEIGENEN Fehlersatz
und wurde rot -- zu Recht: Der Browser haelt das Formular
vorher an, mein Handler laeuft gar nicht. Die Pruefung hatte
recht, die Erwartung war falsch.
DIE ZWEITE SCHRANKE steht im Skript und wird darunter einzeln
geprueft -- mit abgenommenem `min`, sonst kaeme sie nie zum
Zug. Zwei Schranken sind hier keine Dopplung: Die eine ist
Bedienkomfort, die andere die Regel. Wer `min` morgen
entfernt, hat immer noch die Regel. */
await seite.fill("dialog.nachfrage #nachfrage-datum", "2020-01-01");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(250);
const nochOffen = await seite.evaluate(() => {
const f = document.querySelector("dialog.nachfrage #nachfrage-datum");
return {
offen: !!document.querySelector("dialog.nachfrage[open]"),
zuFrueh: f.validity.rangeUnderflow,
};
});
pruef(`ein Datum vor der Untergrenze haelt den Dialog auf (offen ${nochOffen.offen})`,
nochOffen.offen === true);
pruef(` der Browser nennt es zu frueh (rangeUnderflow ${nochOffen.zuFrueh})`,
nochOffen.zuFrueh === true);
/* UND JETZT OHNE `min`: Nur so kommt die Regel im Skript dran. */
await seite.evaluate(() =>
document.querySelector("dialog.nachfrage #nachfrage-datum").removeAttribute("min"));
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(250);
const eigene = await seite.evaluate(() => ({
offen: !!document.querySelector("dialog.nachfrage[open]"),
fehler: document.querySelector('dialog.nachfrage [data-teil="fehler"]')?.textContent || "",
}));
pruef(`ohne Untergrenze faengt es das Skript ab („${eigene.fehler}")`,
eigene.offen && /Vergangenheit/.test(eigene.fehler));
await seite.fill("dialog.nachfrage #nachfrage-datum", "2030-06-01");
await seite.check("dialog.nachfrage #nachfrage-haken");
await seite.fill("dialog.nachfrage #nachfrage-grund", "Ein Wort dazu.");
await seite.click('dialog.nachfrage [data-teil="ja"]');
await seite.waitForTimeout(400);
const erg5 = await seite.evaluate(async () => {
const a = await window.__probe5;
return a === false ? null : { datum: a.datum, haken: a.haken, grund: a.grund };
});
pruef(`der Dialog gibt den Haken zurueck (${erg5?.haken})`, erg5?.haken === true);
/* UND DAS DATUM IST LEER, weil der Haken es gesperrt hat. Haette
es den Wert behalten, schickte der Browser etwas mit, das der
Server wegwirft -- und beim naechsten Lesen stuende in der
Antwort etwas anderes als in der Datenbank. */
pruef(` und das gesperrte Datum kommt LEER zurueck („${erg5?.datum}")`,
erg5?.datum === "");
pruef(` die Anmerkung kommt mit („${erg5?.grund}")`, erg5?.grund === "Ein Wort dazu.");
} }
pruef("keine Abstürze im Browser", abstuerze.length === 0, abstuerze[0] || ""); pruef("keine Abst\u00fcrze im Browser", abstuerze.length === 0, abstuerze[0] || "");
await browser.close(); await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+37
View File
@@ -71,6 +71,18 @@ anlegen("Patrick", "scout", "CODE-PATR-0001");
anlegen("SpongBobSchwammKopf", "creator", "CODE-SPON-0001"); anlegen("SpongBobSchwammKopf", "creator", "CODE-SPON-0001");
anlegen("Tili", "creator", "CODE-TILI-0001"); anlegen("Tili", "creator", "CODE-TILI-0001");
anlegen("BanaStift", "manager", "CODE-BSTF-0001", 0); // gesperrt anlegen("BanaStift", "manager", "CODE-BSTF-0001", 0); // gesperrt
/* GENAU EINE PERSON IST PER BENACHRICHTIGUNG ERREICHBAR (03.10.2026).
Damit laesst sich der Hinweis "bekommt keine Benachrichtigungen"
in beide Richtungen pruefen: Er muss bei den anderen stehen UND
bei dieser fehlen. Stuende er ueberall, waere die Pruefung "er
steht da" wertlos -- sie koennte gar nichts anderes messen. */
d.prepare(`INSERT INTO push_anmeldungen
(endpunkt, person_id, p256dh, auth, geraet, erstellt, zuletzt_ok, fehler)
VALUES (?, (SELECT id FROM personen WHERE name = 'Patrick'), ?,?,?,?,?,0)`)
.run("https://example.invalid/push/patrick", "p256dh-platzhalter",
"auth-platzhalter", "Pruefgeraet", jetzt, jetzt);
d.close(); d.close();
const { chromium } = await import( const { chromium } = await import(
@@ -91,6 +103,13 @@ async function seiteFuer(kontext) {
const lesen = (seite) => seite.evaluate(() => ({ const lesen = (seite) => seite.evaluate(() => ({
namen: [...document.querySelectorAll(".person__name")].map((n) => n.textContent.replace(" (du)", "")), namen: [...document.querySelectorAll(".person__name")].map((n) => n.textContent.replace(" (du)", "")),
/* Zu wem gehoert welcher Hinweis? Die Kachel ist die Klammer --
sonst waere nicht zu unterscheiden, ob der Hinweis bei Patrick
oder bei seinem Nachbarn steht. */
ohnePush: [...document.querySelectorAll(".person")]
.filter((k) => [...k.querySelectorAll(".person__bezug")]
.some((z) => /keine Benachrichtigungen/.test(z.textContent)))
.map((k) => k.querySelector(".person__name")?.textContent.replace(" (du)", "")),
abschnitte: [...document.querySelectorAll(".gruppe")].map((g) => ({ abschnitte: [...document.querySelectorAll(".gruppe")].map((g) => ({
name: g.querySelector(".gruppe__name")?.textContent, name: g.querySelector(".gruppe__name")?.textContent,
zahl: g.querySelector(".gruppe__zahl")?.textContent, zahl: g.querySelector(".gruppe__zahl")?.textContent,
@@ -188,6 +207,24 @@ console.log("\n3) Der Sammelknopf klappt alles auf und wieder zu");
`Zusatztext wörtlich von der Zugangsseite ("${r.abschnitte[2]?.unter}")`); `Zusatztext wörtlich von der Zugangsseite ("${r.abschnitte[2]?.unter}")`);
} }
console.log("\n3b) Wer bekommt ueberhaupt eine Benachrichtigung?");
{
const r = await lesen(seite);
/* Am 03.10.2026 nachgemessen: 20 aktive Personen im Haus, 8 mit
einer Anmeldung. Zwoelf bekamen nichts, und niemand konnte das
sehen -- auch sie selbst nicht. */
ok(r.ohnePush.length > 0 && r.ohnePush.includes("Tili"),
`der Hinweis steht bei den Unerreichbaren (${r.ohnePush.join(", ") || "nirgends"})`);
ok(!r.ohnePush.includes("Patrick"),
"aber NICHT bei Patrick, der ein Geraet angemeldet hat");
/* Bei einer gesperrten Person ist es keine Luecke, sondern richtig
so -- sie soll ja nichts mehr bekommen. */
ok(!r.ohnePush.includes("BanaStift"),
"und nicht bei der gesperrten Person (dort waere es kein Mangel)");
ok(r.ohnePush.length === r.namen.length - 2,
`genau die ${r.namen.length - 2} aktiven ohne Geraet (${r.ohnePush.length})`);
}
console.log("\n4) Der Zustand überlebt das Neuladen – je Rolle einzeln"); console.log("\n4) Der Zustand überlebt das Neuladen – je Rolle einzeln");
{ {
/* Nur die Creator zuklappen. Es muss die EINZELNE Einstellung /* Nur die Creator zuklappen. Es muss die EINZELNE Einstellung
+330
View File
@@ -0,0 +1,330 @@
/* =====================================================================
WECKT DIE MELDUNG DAS TELEFON -- oder darf sie warten?
WARUM ES DIESE PRUEFUNG GIBT (03.10.2026).
Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."
Alles, was wir bis dahin messen konnten, war gruen: Der Service
Worker zeigt die Meldung (pruef-push-zu, 13 Pruefungen), jede
Anmeldung im Haus stand auf `fehler = 0`, und der Push-Dienst
quittierte jede Zustellung mit 201. Nur hat niemand den Kopf
gemessen, der auf dem Weg mitgeht:
Urgency: normal
Beide Dienste behandeln das ausdruecklich als "darf warten".
Android/FCM haelt solche Meldungen zurueck, solange das Telefon
doest, und stellt sie zu, wenn es aufwacht -- typischerweise, wenn
jemand entsperrt oder die App oeffnet. Apple/APNs nennt es
"verzoegert, gebuendelt oder gedrosselt". Das ist Dienes Satz, Wort
fuer Wort, und es stand als Vorgabe in unserem eigenen Quelltext.
`zuletzt_ok` konnte das nie zeigen: Es beweist, dass der DIENST
angenommen hat (201), nicht dass das GERAET etwas angezeigt hat.
Zwischen beidem liegt genau dieser Kopf.
WAS HIER GEMESSEN WIRD, und warum in dieser Reihenfolge:
1. Die ENTSCHEIDUNG je Art -- und zwar unabhaengig notiert, nicht
aus der Artenliste abgelesen. Eine Pruefung, die ihre Erwartung
aus derselben Tabelle holt, die sie prueft, ist ein Spiegel:
Sie ist immer gruen, auch wenn jemand die Tabelle falsch
aendert.
2. VOLLSTAENDIGKEIT -- jede Art muss unten eine Entscheidung
haben. Wer eine neue eintraegt, ohne sich zu entscheiden,
bekommt einen Fehler statt stillschweigend "darf warten".
3. Der KOPF AUF DER LEITUNG, durch `benachrichtige` hindurch bis
zu einem nachgebauten Push-Dienst. Nur das beweist, dass die
Entscheidung auch ankommt.
4. Dass Dringlichkeit und HALTBARKEIT getrennt bleiben. Das war
die Falle beim Beheben: Beides hing an einem Schalter, und ein
eiliger Chat haette damit TTL 150 bekommen -- wer sein Telefon
drei Minuten aus hat, haette die Nachricht dann GAR nicht mehr
bekommen. Aus "zu spaet" waere "nie" geworden.
GEGENPROBE: Der alte Name `dringend` muss krachen, und eine nicht
eilige Art muss nachweislich "normal" bekommen -- sonst koennte
diese Pruefung gar nichts anderes als "high" sehen.
DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen.
===================================================================== */
/* ---- DIE UHR IST HIER EINE EINGABE, KEINE ANNAHME --------------------
`istRuhezeit` fragt `Date.getHours()`, also die Zeitzone dieses
Prozesses. Liefe die Pruefung um 23 Uhr, wuerde `benachrichtige` zu
Recht nichts verschicken, und alles unten saehe nach Befund aus,
ohne dass etwas kaputt waere -- eine Zeitbombe wie die
Gate-Pruefung vom 06.09. Deshalb wird die Zone so gewaehlt, dass
JETZT auf 12 Uhr mittags faellt, egal wann der Lauf startet. */
const utcStunde = new Date().getUTCHours();
const versatz = ((12 - utcStunde + 12) % 24) - 12; /* -12 .. +11 */
process.env.TZ = versatz === 0 ? "Etc/GMT"
: `Etc/GMT${versatz > 0 ? "-" : "+"}${Math.abs(versatz)}`; /* POSIX: Vorzeichen umgekehrt */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:http";
import { createECDH, randomBytes } from "node:crypto";
const ordner = mkdtempSync(join(tmpdir(), "ws-eilig-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "pruef-push-eilig", 0);
const PORT_DIENST = await eigenerPort(import.meta, "pruef-push-eilig (Push-Dienst)", 1);
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
await import("./index.js");
notbremse(120_000, "pruef-push-eilig");
await new Promise((r) => setTimeout(r, 900));
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const abbruch = (grund) => {
console.log(`\n KONNTE NICHT NACHSEHEN: ${grund}`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
};
const push = await import("./workspace-push.js");
const { b64u } = await import("./workspace-push-krypto.js");
const { DatabaseSync } = await import("node:sqlite");
/* =====================================================================
DIE ENTSCHEIDUNG -- von Hand notiert, nicht abgelesen.
Massstab: eilig ist, was durch Warten WERTLOS wird. Nicht eilig
ist, was auch eine halbe Stunde spaeter noch stimmt. Waere alles
eilig, waere nichts mehr eilig -- die Geraete duerfen uns die
Dringlichkeit glauben, sonst drosseln sie uns, und der Akku zahlt.
===================================================================== */
const ERWARTET = {
/* --- weckt das Geraet: jemand wartet, oder es ist gleich vorbei --- */
anruf: true, /* klingelt 120 Sekunden */
test: true, /* jemand steht davor und tippt "Probe" */
chat_nachricht: true, /* jemand wartet auf eine Antwort */
chat_erwaehnung: true, /* jemand meint DICH */
support: true, /* Meldung oder Antwort darauf */
hilfe: true, /* vertrauliche Meldung */
termin_gleich: true, /* in einer Stunde -- danach wertlos */
termin_wecker: true, /* selbst gestellt; ein spaeter Wecker ist keiner */
dogfather_live: true, /* "jetzt" oder gar nicht */
reaktion_live: true, /* dito */
/* --- darf warten: stimmt spaeter auch noch ----------------------- */
aufgabe_faellig: false, /* morgen faellig */
aufgabe_ueberfaellig: false, /* ist es schon */
protokoll_fehlt: false,
followup: false,
bewerbung_neu: false,
bewerbung_antwort: false,
manager_ziele: false,
tagesruf: false, /* Tagesuebersicht */
befinden: false, /* alle zwei Wochen */
};
console.log("\n=== Die Entscheidung je Art ===");
{
const arten = push.ARTEN.map((a) => a.schluessel);
/* Vollstaendigkeit in BEIDE Richtungen -- eine neue Art ohne
Entscheidung ist ein Fehler, und eine Entscheidung ohne Art ist
ein Rest, der niemanden mehr schuetzt. */
const ohneEntscheidung = arten.filter((a) => !(a in ERWARTET));
const ohneArt = Object.keys(ERWARTET).filter((a) => !arten.includes(a));
ok(arten.length > 0 && ohneEntscheidung.length === 0,
ohneEntscheidung.length
? `OHNE ENTSCHEIDUNG: ${ohneEntscheidung.join(", ")} -- eilig oder nicht?`
: `alle ${arten.length} Arten haben eine Entscheidung`);
ok(ohneArt.length === 0,
ohneArt.length ? `Entscheidung ohne Art: ${ohneArt.join(", ")}` : "keine Karteileiche");
let stimmt = 0;
for (const [art, soll] of Object.entries(ERWARTET)) {
if (push.eiligRegel(art) === soll) stimmt++;
else ok(false, `${art}: erwartet ${soll ? "eilig" : "darf warten"}, ist es nicht`);
}
const anzahl = Object.keys(ERWARTET).length;
ok(anzahl > 0 && stimmt === anzahl, `${stimmt} von ${anzahl} Arten wie entschieden`);
const eilige = arten.filter((a) => push.eiligRegel(a)).length;
ok(eilige > 0 && eilige < arten.length,
`${eilige} von ${arten.length} sind eilig -- geteilt, nicht pauschal`);
ok(push.eiligRegel("gibtsnicht") === false,
"eine unbekannte Art ist NICHT eilig (im Zweifel nicht wecken)");
}
/* =====================================================================
Der Kopf auf der Leitung
===================================================================== */
console.log("\n=== Was wirklich beim Push-Dienst ankommt ===");
const empfangen = [];
const dienst = createServer((q, a) => {
q.on("data", () => {});
q.on("end", () => { empfangen.push(q.headers); a.writeHead(201); a.end(); });
});
await new Promise((r) => dienst.listen(PORT_DIENST, "127.0.0.1", r));
const browser = createECDH("prime256v1");
browser.generateKeys();
let d;
try { d = new DatabaseSync(process.env.WORKSPACE_DB); }
catch (e) { abbruch(`die Testdatenbank laesst sich nicht oeffnen: ${e.message}`); }
const jetzt = new Date().toISOString();
/* Der Zugangscode ist hier BEDEUTUNGSLOS -- angemeldet wird sich
nicht, gemessen wird der Weg nach draussen. Die Spalten sind nur
NOT NULL, also stehen dort Zufallsbytes: kein echter Code, kein
erfundener, und keiner, der jemandem Zugang gaebe. */
const fuellung = randomBytes(32).toString("hex");
d.prepare(`INSERT INTO personen
(name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)
VALUES (?,?,?,?,?,1,?)`)
.run("Probeperson", "admin", fuellung, fuellung.slice(0, 32), 32768, jetzt);
const personId = d.prepare("SELECT id FROM personen WHERE name = 'Probeperson'").get().id;
d.prepare(`INSERT INTO push_anmeldungen
(endpunkt, person_id, p256dh, auth, geraet, erstellt, zuletzt_ok, fehler)
VALUES (?,?,?,?,?,?,NULL,0)`)
.run(`http://127.0.0.1:${PORT_DIENST}/push/probe`, personId,
b64u(browser.getPublicKey()), b64u(Buffer.alloc(16, 7)), "Pruefgeraet", jetzt);
d.close();
/** Verschickt EINE Meldung und gibt den Kopf zurueck, der ankam. */
const kopfVon = async (art) => {
const vorher = empfangen.length;
const e = await push.benachrichtige(personId, art, {
titel: "Probe", text: art, ziel: "/workspace/start.html",
});
if (e.verschickt !== 1) return { grund: e.grund };
return { kopf: empfangen[vorher], grund: e.grund };
};
{
/* Je ein Vertreter beider Seiten -- beide mit `ruhe: "immer"`, damit
die Messung nicht an der Ruhezeit haengt (die oben auf Mittag
festgenagelt ist). */
const eilig = await kopfVon("chat_nachricht");
const ruhig = await kopfVon("aufgabe_faellig");
if (!eilig.kopf || !ruhig.kopf) {
abbruch("es ging nichts hinaus "
+ `(chat_nachricht: ${eilig.grund}, aufgabe_faellig: ${ruhig.grund}) `
+ "-- ohne eine zugestellte Meldung ist der Kopf nicht messbar");
}
ok(eilig.kopf.urgency === "high",
`chat_nachricht geht mit Urgency: ${eilig.kopf.urgency} hinaus (muss "high" sein)`);
ok(ruhig.kopf.urgency === "normal",
`aufgabe_faellig geht mit Urgency: ${ruhig.kopf.urgency} hinaus (muss "normal" sein)`);
/* DIE GEGENPROBE: Saehe diese Pruefung grundsaetzlich nur "high",
waere die Zeile darueber wertlos. Dass sich die beiden Koepfe
UNTERSCHEIDEN, ist der Beweis, dass gemessen und nicht geraten
wird. */
ok(eilig.kopf.urgency !== ruhig.kopf.urgency,
"die beiden unterscheiden sich -- es wird wirklich gemessen");
/* HALTBARKEIT: der eigentliche Grund, warum es zwei Felder sind.
Ein eiliger Chat darf NICHT nebenbei kurzlebig werden. */
ok(eilig.kopf.ttl === "3600",
`chat_nachricht bleibt ${eilig.kopf.ttl} s gueltig (muss 3600 sein, nicht 150)`);
ok(ruhig.kopf.ttl === "3600", `aufgabe_faellig: TTL ${ruhig.kopf.ttl}`);
/* Und das, was kurzlebig sein SOLL, ist es noch: ein Anruf von vor
einer Stunde darf nicht nachtraeglich aufploppen. */
const anruf = await kopfVon("anruf");
if (!anruf.kopf) abbruch(`der Anruf ging nicht hinaus (${anruf.grund})`);
ok(anruf.kopf.urgency === "high", `anruf: Urgency ${anruf.kopf.urgency}`);
ok(anruf.kopf.ttl === "150",
`anruf verfaellt nach ${anruf.kopf.ttl} s -- eilig UND kurzlebig, beides zugleich`);
}
/* =====================================================================
Das Sendeprotokoll -- damit "ich bekomme nichts" beantwortbar wird
===================================================================== */
console.log("\n=== Was hinausging, steht hinterher da ===");
{
const p = new DatabaseSync(process.env.WORKSPACE_DB);
const zaehle = (wo = "1=1", ...w) =>
p.prepare(`SELECT COUNT(*) AS n FROM push_versand WHERE ${wo}`).get(...w).n;
/* Drei Meldungen sind oben wirklich hinausgegangen. */
const geschickt = zaehle("grund = 'ok'");
ok(geschickt >= 3, `${geschickt} zugestellte Meldungen stehen im Protokoll (mindestens 3)`);
const mitGeraet = zaehle("grund = 'ok' AND geraete >= 1 AND zugestellt >= 1");
ok(mitGeraet === geschickt,
`bei allen ${mitGeraet} steht, an wie viele Geraete (nicht nur DASS)`);
const arten = p.prepare(
"SELECT DISTINCT art FROM push_versand ORDER BY art").all().map((r) => r.art);
ok(arten.includes("chat_nachricht") && arten.includes("anruf"),
`die Art steht dabei: ${arten.join(", ")}`);
/* DER EIGENTLICHE ZWECK: auch das NICHT-Senden muss dastehen.
"Abgeschaltet", "Ruhezeit", "kein Geraet" sind die haeufigsten
Antworten auf "ich bekomme nichts" -- ein Protokoll, das nur
Erfolge kennt, kann die Frage nicht beantworten, fuer die es
angelegt wurde. */
const vorher = zaehle();
const nichts = await push.benachrichtige(personId, "gibtsnicht", {
titel: "x", text: "x", ziel: "/workspace/start.html",
});
const nachher = zaehle();
ok(nichts.verschickt === 0 && nichts.grund === "unbekannte_art",
`es ging nichts hinaus (${nichts.grund})`);
ok(nachher === vorher + 1,
`und genau DAS steht jetzt auch da (${vorher} -> ${nachher})`);
ok(zaehle("grund = 'unbekannte_art'") === 1,
"mit dem Grund, nicht nur als leere Zeile");
/* Gegenprobe: Koennte diese Messung ueberhaupt "nein" sagen? Eine
Art, die es nicht gibt, darf nicht als zugestellt gelten -- sonst
waere jede Zeile oben wertlos. */
ok(zaehle("grund = 'ok' AND art = 'gibtsnicht'") === 0,
"Gegenprobe: der Fehlschlag steht NICHT als zugestellt drin");
p.close();
}
/* =====================================================================
Gegenprobe: der alte Name darf nicht still durchrutschen
===================================================================== */
console.log("\n=== Der alte Schalter ist weg -- und zwar laut ===");
{
const { schicken, schluesselErzeugen } = await import("./workspace-push-krypto.js");
let krachte = false;
try {
await schicken({ endpunkt: `http://127.0.0.1:${PORT_DIENST}/alt`,
p256dh: b64u(browser.getPublicKey()), auth: b64u(Buffer.alloc(16, 7)) },
"{}", schluesselErzeugen(), "mailto:[email protected]",
{ dringend: true });
} catch { krachte = true; }
ok(krachte,
"schicken({ dringend: true }) bricht ab -- sonst gaebe es klaglos \"normal\" zurueck");
}
/* ---- SAUBER ZUMACHEN, SONST IST GRUEN TROTZDEM ROT ------------------
`fetch` haelt die Verbindungen zum nachgebauten Dienst offen
(keep-alive). Wer dann `process.exit()` ruft, reisst libuv ein
Handle mitten im Schliessen weg, und Windows antwortet mit
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)
-- Rueckgabewert 127 NACH "13 Pruefungen, 0 Fehler". Genau das ist
am 05.09. schon einmal passiert. Eine Pruefung, die inhaltlich
besteht und trotzdem rot meldet, ist das Schlimmste von beidem:
Man gewoehnt sich an, das Rot zu uebersehen. Also erst die
Verbindungen kappen, dann zumachen, dann einen Takt warten. */
dienst.closeAllConnections?.();
await new Promise((r) => dienst.close(r));
await new Promise((r) => setTimeout(r, 250));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler ? 1 : 0);
+311
View File
@@ -0,0 +1,311 @@
/* =====================================================================
"DU BEKOMMST GERADE NICHTS" -- der Hinweis auf der Startseite.
WARUM ES IHN GIBT (03.10.2026).
Am 03.10. am laufenden System nachgemessen: 20 aktive Personen, 8
mit einer Push-Anmeldung. Zwoelf bekamen keine einzige
Benachrichtigung -- darunter die linke Hand und ein Modi. Keiner
von ihnen wusste es: Die Glocke sagt es nur dem, der sie anschaut.
WAS HIER WIRKLICH AUF DEM SPIEL STEHT, ist nicht "erscheint der
Hinweis". Das ist der leichte Teil. Die Gefahr liegt auf der
anderen Seite: Ein Hinweis, der auch denen erscheint, die laengst
alles angeschaltet haben, oder der nach dem Wegklicken
wiederkommt, ist eine Warnung, die immer kommt -- und die liest
nach dem zweiten Mal niemand mehr. Dann haette ich die Glocke
entwertet, die heute funktioniert.
Deshalb misst diese Pruefung VIER Abwesenheiten und nur eine
Anwesenheit:
* weg, sobald jemand "Nicht jetzt" tippt
* weg geblieben nach dem Neuladen (sonst ist es kein "einmalig")
* gar nicht erst da auf einer anderen Seite (er haengt an 39
Seiten mit, wenn man es falsch baut)
* gar nicht erst da, wenn die Benachrichtigungen AN sind
Die letzte ist die teuerste -- sie braucht ein echtes Fenster und
eine echte Anmeldung -- und sie ist die wichtigste.
MIT FENSTER, UND ZWAR NOTWENDIG: Ein kopfloser Chromium verweigert
Benachrichtigungen, egal wie man die Erlaubnis erteilt (am
02.10.2026 in fuenf Aufbauten gemessen, siehe pruef-push-zu). Ohne
erteilte Erlaubnis kommt `lage()` nie auf "an", und die
entscheidende Gegenprobe waere nicht messbar.
DREI AUSGAENGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht nachsehen.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-hinweis-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "pruef-push-hinweis");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
notbremse(300_000, "pruef-push-hinweis");
await new Promise((r) => setTimeout(r, 900));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }),
}).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run("Filipe", "admin", hash, salt, 32768, new Date().toISOString());
d.close();
}
const { chromium } = await import("playwright");
const browser = await chromium.launch({ headless: false });
const abbruch = async (grund) => {
console.log(`\n KONNTE NICHT NACHSEHEN: ${grund}`);
try { await browser.close(); } catch { /* egal */ }
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
};
/** Meldet sich an und landet auf der Startseite. */
async function anmelden(kontext) {
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 20000 });
await seite.waitForTimeout(700);
return seite;
}
/** Was steht gerade im Hinweis -- und wie gross ist es? */
const hinweisLesen = (seite) => seite.evaluate(() => {
const k = document.querySelector("#push-hinweis .push-hinweis");
if (!k) return { da: false };
const t = k.querySelector(".push-hinweis__text");
const knoepfe = [...k.querySelectorAll(".push-hinweis__knopf")].map((b) => ({
text: b.textContent.trim(),
hoch: Math.round(b.getBoundingClientRect().height),
breit: Math.round(b.getBoundingClientRect().width),
}));
const stil = getComputedStyle(t);
return {
da: true,
text: t.textContent.replace(/\s+/g, " ").trim(),
schrift: parseFloat(stil.fontSize),
farbe: stil.color,
knoepfe,
};
});
/* =====================================================================
1. Ohne Anmeldung: der Hinweis steht da
===================================================================== */
console.log("\n=== Wer nichts bekommt, erfaehrt es ===");
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
});
const seite = await anmelden(kontext);
{
const erlaubnis = await seite.evaluate(() => Notification.permission);
if (erlaubnis === "granted") {
await abbruch("der Browser hat Benachrichtigungen schon erlaubt -- dann "
+ "laesst sich der Fall \"noch nicht angeschaltet\" hier nicht messen");
}
const h = await hinweisLesen(seite);
if (!h.da) {
await abbruch("auf der Startseite steht gar kein Hinweis -- ohne ihn "
+ "ist nichts von dem unten Messbares messbar");
}
ok(/keine Benachrichtigungen/.test(h.text),
`er sagt, worum es geht ("${h.text.slice(0, 64)}…")`);
ok(h.knoepfe.length === 2, `zwei Knoepfe: ${h.knoepfe.map((b) => b.text).join(" / ")}`);
ok(h.knoepfe.some((b) => /Anschalten/.test(b.text)),
"einer davon schaltet es an -- der Hinweis ist nicht nur eine Mitteilung");
/* Lesbarkeit: im Haus gilt keine Schrift unter 11,5 px, und ein
Ziel, das man mit dem Daumen trifft, ist mindestens 44 px hoch. */
ok(h.schrift >= 11.5, `Schriftgroesse ${h.schrift} px (mindestens 11,5)`);
const klein = h.knoepfe.filter((b) => b.hoch < 44);
ok(klein.length === 0,
klein.length ? `zu flache Knoepfe: ${klein.map((b) => b.text + " " + b.hoch + "px")}`
: `beide Knoepfe mindestens 44 px hoch (${h.knoepfe.map((b) => b.hoch).join(", ")})`);
/* Auf 390 px darf nichts ueber den Rand stehen. */
const ueber = await seite.evaluate(() => {
const k = document.querySelector("#push-hinweis .push-hinweis");
return Math.round(k.getBoundingClientRect().right - document.documentElement.clientWidth);
});
ok(ueber <= 0, `nichts steht ueber (${ueber} px)`);
/* EIN BILD DAZU. Zahlen sagen, dass er passt -- nicht, ob er
aussieht wie etwas, das man anfassen moechte.
ERST HINSCROLLEN. Der erste Versuch zeigte brav die
Begruessungskachel: Der Hinweis steht darunter und war im Bild
gar nicht drin. Ein Beweisbild ohne das, was es beweisen soll,
ist schlimmer als keins -- man glaubt ja, hingesehen zu haben. */
await seite.evaluate(() => document.querySelector("#push-hinweis")
.scrollIntoView({ block: "center" }));
await seite.waitForTimeout(400);
await seite.screenshot({
path: new URL("./pruef-push-hinweis.png", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1"),
});
}
/* =====================================================================
2. Auf anderen Seiten NICHT -- er haengt an 39 Seiten mit
===================================================================== */
console.log("\n=== Nur auf der Startseite ===");
{
await seite.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
const h = await hinweisLesen(seite);
ok(!h.da, "auf der Aufgabenseite steht er nicht");
/* Gegenprobe zur Gegenprobe: Die Glocke ist dort sehr wohl da --
sonst wuerde oben nur gemessen, dass glocke.js gar nicht laeuft. */
const glocke = await seite.evaluate(() => !!document.getElementById("glocke"));
ok(glocke, "die Glocke selbst schon -- glocke.js laeuft dort also");
await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
ok((await hinweisLesen(seite)).da, "und auf der Startseite wieder");
}
/* =====================================================================
3. "Nicht jetzt" heisst nicht jetzt und auch nicht morgen
===================================================================== */
console.log("\n=== Einmal weggetippt, bleibt weg ===");
{
await seite.click('.push-hinweis__knopf--spaeter');
await seite.waitForTimeout(300);
ok(!(await hinweisLesen(seite)).da, "nach dem Tippen ist er weg");
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(800);
ok(!(await hinweisLesen(seite)).da,
"und nach dem Neuladen immer noch -- sonst waere es keine einmalige Sache");
}
/* =====================================================================
4. DIE WICHTIGSTE: Wer sie AN hat, wird nicht behelligt
===================================================================== */
console.log("\n=== Wer sie an hat, sieht nichts davon ===");
{
/* ==== EIN ECHTES PROFIL, KEIN INKOGNITO-FENSTER =================
Erst gemessen, dann verstanden (03.10.2026): `browser.newContext()`
gibt ein Inkognito-Fenster, und dort meldete die Glocke hartnaeckig
"aus". Der Browser hat es im Klartext gesagt, sobald die Pruefung
ihn gefragt hat:
"Chrome currently does not support the Push API in incognito
mode. There is deliberately no way to feature-detect this,
since incognito mode needs to be undetectable by websites."
Also: kein Befund am Haus, sondern eine Eigenschaft des Fensters
-- und einer, den man NICHT abfragen kann. Ohne das Mitschreiben
der Browsermeldungen haette hier eine Stunde Suche im eigenen
Code gestanden.
`launchPersistentContext` mit einem eigenen Ordner ist ein
richtiges Profil. Der Ordner wird am Ende weggeraeumt. */
const profil = mkdtempSync(join(tmpdir(), "ws-hinweis-profil-"));
const k2 = await chromium.launchPersistentContext(profil, {
headless: false,
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
permissions: ["notifications"],
});
await k2.grantPermissions(["notifications"], { origin: BASIS });
const s2 = await anmelden(k2);
const erlaubnis = await s2.evaluate(() => Notification.permission);
if (erlaubnis !== "granted") {
await abbruch("der Browser erlaubt keine Benachrichtigungen "
+ `(Notification.permission = "${erlaubnis}") -- ein kopfloser Chromium `
+ "sagt immer „denied“. Diese Pruefung braucht ein Fenster.");
}
/* Vor dem Anschalten muss er da sein -- sonst beweist sein Fehlen
danach gar nichts (er koennte aus einem ganz anderen Grund
fehlen, etwa weil der neue Kontext nie fertig geladen hat). */
ok((await hinweisLesen(s2)).da,
"im frischen Browser steht er zuerst da (sonst beweist sein Fehlen nichts)");
/* Mitschreiben, was der Browser sagt -- scheitert das Anmelden,
steht der Grund hier und nicht in einer Vermutung. */
const klagen = [];
s2.on("console", (m) => { if (m.type() === "error") klagen.push(m.text()); });
s2.on("pageerror", (f) => klagen.push(String(f?.message || f)));
await s2.click(".push-hinweis__knopf--ja");
/* AUF DEN ZUSTAND WARTEN, NICHT AUF DIE UHR. Ein fester Schlaf ist
entweder zu kurz (dann ist es ein Fehlalarm) oder zu lang (dann
wartet jeder Lauf umsonst). `pushManager.subscribe` spricht mit
einem echten Push-Dienst im Netz -- wie lange das dauert, weiss
nur er. */
let lage = null;
for (let i = 0; i < 60; i++) {
lage = await s2.evaluate(() => document.getElementById("glocke")?.dataset.lage);
if (lage === "an") break;
await s2.waitForTimeout(500);
}
if (lage !== "an") {
await abbruch(`das Anschalten hat nicht geklappt (Glocke steht auf "${lage}") `
+ "-- ohne eine echte Anmeldung ist die Gegenprobe nicht messbar."
+ (klagen.length ? `\n Der Browser sagt dazu: ${klagen.slice(0, 3).join(" | ")}`
: "\n Der Browser hat sich dazu nicht geaeussert. Moeglich ist, dass "
+ "pushManager.subscribe() keinen echten Push-Dienst erreicht "
+ "(dafuer braucht dieser Rechner eine Verbindung ins Netz)."));
}
ok(lage === "an", "nach dem Antippen steht die Glocke auf „an“");
ok(!(await hinweisLesen(s2)).da,
"und der Hinweis ist weg -- ohne dass jemand ihn wegklicken musste");
await s2.reload({ waitUntil: "networkidle" });
await s2.waitForTimeout(1200);
ok(!(await hinweisLesen(s2)).da, "auch nach dem Neuladen bleibt er weg");
/* Und das Angemeldetsein ist echt: Es steht in der Datenbank. */
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const n = d.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
d.close();
ok(n === 1, `die Anmeldung steht wirklich in der Datenbank (${n})`);
await k2.close();
try { rmSync(profil, { recursive: true, force: true }); } catch { /* egal */ }
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
process.exit(fehler ? 1 : 0);
+90 -2
View File
@@ -84,9 +84,50 @@ for (const datei of serverDateien) {
Die Erfassung der AUFRUFE kannte alle drei Zeichen schon Die Erfassung der AUFRUFE kannte alle drei Zeichen schon
(["'`]) -- nur die Erfassung der ROUTEN nicht. Zwei Regeln fuer (["'`]) -- nur die Erfassung der ROUTEN nicht. Zwei Regeln fuer
dieselbe Frage, und eine davon war aelter. */ dieselbe Frage, und eine davon war aelter. */
/* ==== AUCH ROUTEN HINTER EINER KONSTANTEN (02.10.2026) =========
Gemeldet wurde „Schnittstelle gibt es nicht ->
/workspace/api/manager-ziele". Nachgesehen statt geglaubt: Die
Adresse wird nie aufgerufen; sie ist eine Konstante, und benutzt
wird `${BASIS}/stand`, `${BASIS}/eintraege` und acht weitere.
DER EIGENTLICHE FUND LAG EINE EBENE TIEFER: Der SERVER
registriert sie genauso --
const BASIS = "/workspace/api/manager-ziele";
managerZieleRouter.get(`${BASIS}/stand`, ...)
-- und die Sammelregel hier verlangte, dass der Pfad direkt mit
`/workspace/` beginnt. ALLE ZEHN Routen dieses Moduls fehlten
damit in der Liste. Die Wache war nicht zu streng, sie war
unvollstaendig: Sie konnte zu diesen Routen gar nichts sagen --
weder dass es sie gibt, noch dass es sie nicht gibt.
Jetzt werden einfache Praefix-Konstanten je Datei aufgeloest.
Mehr nicht: Wer seinen Pfad aus drei Variablen zusammensetzt,
bleibt unauffindbar -- und das ist richtig so, denn geraten wird
hier nicht. */
const konstanten = new Map();
for (const k of text.matchAll(
/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*["'`](\/workspace\/[^"'`]*)["'`]\s*;/g)) {
konstanten.set(k[1], k[2]);
}
const treffer = text.matchAll( const treffer = text.matchAll(
/Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*["'`](\/workspace\/[^"'`]*)/g); /Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*["'`](\$\{[A-Za-z_$][\w$]*\})?(\/workspace\/[^"'`]*|\/[^"'`]*)/g);
for (const t of treffer) ROUTEN.push({ art: t[1], pfad: t[2], datei }); for (const t of treffer) {
let pfad = t[3];
if (t[2]) {
const name = t[2].slice(2, -1);
const wert = konstanten.get(name);
/* OHNE BEKANNTEN WERT WIRD NICHTS EINGETRAGEN. Eine geratene
Route waere schlimmer als eine fehlende: Sie liesse einen
toten Aufruf als lebendig durchgehen. */
if (!wert) continue;
pfad = wert + pfad;
}
if (!pfad.startsWith("/workspace/")) continue;
ROUTEN.push({ art: t[1], pfad, datei });
}
} }
ok(ROUTEN.length >= 80, `${ROUTEN.length} Server-Routen eingelesen`); ok(ROUTEN.length >= 80, `${ROUTEN.length} Server-Routen eingelesen`);
@@ -209,6 +250,37 @@ for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
} }
continue; continue;
} }
/* ==== EINE PRAEFIX-KONSTANTE IST KEIN AUFRUF (02.10.2026) =====
Gemeldet wurde:
assets/js/manager-ziele.js: Schnittstelle gibt es nicht
-> /workspace/api/manager-ziele
Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE
auf. In Zeile 31 steht
const BASIS = '/workspace/api/manager-ziele';
und benutzt wird sie ausschliesslich als `${BASIS}/stand`,
`${BASIS}/eintraege`, `${BASIS}/eintrag/${id}` -- zehn Routen
gibt es darunter, alle vorhanden.
Der Zweig darueber faengt nur Praefixe mit Schraegstrich am
Ende ab. Eine Konstante ohne Schraegstrich fiel durch und wurde
als fehlende Schnittstelle gemeldet. Ein Fehlalarm, der bei
jedem Lauf kommt -- und eine Warnung, die immer kommt, nimmt
irgendwann die echte mit.
DIE REGEL IST ABGELEITET, NICHT AUFGEZAEHLT: Gibt es unter
diesem Pfad Routen, aber keine EXAKT darauf, dann ist es ein
Praefix. Eine Ausnahmeliste mit „manager-ziele" darin waere die
naechste, die niemand pflegt. */
if (!routeGibtEs(roh)
&& ROUTEN.some((r) => r.art !== "use" && r.pfad.startsWith(roh + "/"))) {
nurPraefix++;
continue;
}
geprueftApi++; geprueftApi++;
if (!routeGibtEs(roh)) ok(false, `${name}: Schnittstelle gibt es nicht -> ${roh}`); if (!routeGibtEs(roh)) ok(false, `${name}: Schnittstelle gibt es nicht -> ${roh}`);
} }
@@ -1443,6 +1515,22 @@ ok(routeGibtEs("/workspace/api/aufgaben"),
ok(!existsSync(join(WS, "gibtesnicht.html")), ok(!existsSync(join(WS, "gibtesnicht.html")),
"erfundene Seite wird als fehlend erkannt"); "erfundene Seite wird als fehlend erkannt");
/* DIE NEUE PRAEFIX-REGEL IN BEIDE RICHTUNGEN (02.10.2026).
Sie laesst etwas durch -- also muss nachgewiesen sein, dass sie
nicht ALLES durchlaesst. Sonst waere aus einer Wache eine
Freigabestelle geworden. */
{
const istPraefix = (p) => !routeGibtEs(p)
&& ROUTEN.some((r) => r.art !== "use" && r.pfad.startsWith(p + "/"));
ok(istPraefix("/workspace/api/manager-ziele"),
"eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)");
ok(!istPraefix("/workspace/api/gibtesnicht"),
" eine erfundene Adresse OHNE Routen darunter bleibt ein Fund");
ok(!istPraefix("/workspace/api/aufgaben"),
" und eine echte Route bleibt eine echte Route, kein Praefix");
}
/* ===================================================================== /* =====================================================================
RUFT JEMAND `openssl` WIEDER DIREKT AUF? RUFT JEMAND `openssl` WIEDER DIREKT AUF?
+90
View File
@@ -189,6 +189,96 @@ try {
ok(konsole.length === 0, konsole.length ? konsole[0] : "keine Abstuerze"); ok(konsole.length === 0, konsole.length ? konsole[0] : "keine Abstuerze");
await seite.screenshot({ path: "pruef-suchfeld.png", fullPage: false }); await seite.screenshot({ path: "pruef-suchfeld.png", fullPage: false });
await kontext.close(); await kontext.close();
/* =====================================================================
EINE GROESSENAENDERUNG DARF DIE LISTE NICHT SCHLIESSEN (05.10.2026)
Diene: "Beim Kalender laesst sich die Art noch nicht einstellen, das
Menue ploppt nur ganz kurz auf und verschwindet direkt wieder. Das
selbe bei den Wiederholungen."
Auf dem Rechner faellt das nie auf: Dort aendert sich die
Fenstergroesse nur, wenn jemand sie aendert. Auf einem Handy aendert
sie sich beim Bedienen -- Adressleiste, Tastatur -- und `resize`
feuerte `schliessen()`.
Gemessen wird an GENAU IHREM Fall: Kalenderformular, die beiden
Auswahlfelder "Art" und "Wiederholung", und eine echte
Groessenaenderung dazwischen. Die Liste muss offen bleiben UND
danach an ihrem Knopf sitzen -- offen, aber verrutscht waere nur
die andere Haelfte.
===================================================================== */
{
const kontext = await browser.newContext({
viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true,
});
const seite = await kontext.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
await seite.click('.rolle[data-rolle="admin"]');
await seite.fill("#code", "CODE-DOGI-0001");
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 15000 });
await seite.goto(BASIS + "/workspace/kalender.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
await seite.evaluate(() => document.getElementById("neu-oeffnen")?.click());
await seite.waitForTimeout(600);
for (const [feldId, wie] of [["f-art", "Art"], ["f-takt", "Wiederholung"]]) {
const auf = await seite.evaluate((id) => {
const k = document.getElementById(id)?.closest(".wahl2")?.querySelector(".wahl2__knopf");
if (!k) return "kein Knopf";
k.click();
return !!document.querySelector(".wahl2__liste");
}, feldId);
ok(auf === true, `${wie}: die Liste geht auf (${auf})`);
/* Die Groessenaenderung, die auf dem Handy beim Bedienen entsteht:
Die Adressleiste faehrt ein, das Fenster wird hoeher. */
await seite.setViewportSize({ width: 390, height: 788 });
await seite.waitForTimeout(350);
const danach = await seite.evaluate(() => {
const l = document.querySelector(".wahl2__liste");
if (!l) return { offen: false };
const k = document.querySelector(".wahl2__knopf[aria-expanded='true']")
|| document.querySelector(".wahl2 .wahl2__knopf");
const lr = l.getBoundingClientRect();
const kr = k ? k.getBoundingClientRect() : null;
return {
offen: true,
/* Sitzt sie noch an ihrem Knopf? Mehr als 40 px Abstand waere
"offen, aber verrutscht" -- und das ist kein Erfolg. */
abstand: kr ? Math.round(Math.min(Math.abs(lr.top - kr.bottom),
Math.abs(lr.bottom - kr.top))) : null,
imBild: lr.top >= 0 && lr.bottom <= innerHeight + 1,
};
});
ok(danach.offen === true,
`${wie}: sie bleibt nach der Groessenaenderung offen (${danach.offen})`);
ok(danach.offen && danach.abstand !== null && danach.abstand <= 40,
`${wie}: und sitzt weiter an ihrem Knopf (${danach.abstand} px Abstand)`);
await seite.setViewportSize({ width: 390, height: 844 });
await seite.keyboard.press("Escape");
await seite.waitForTimeout(250);
}
/* GEGENPROBE: Escape schliesst sie weiterhin. Sonst haette ich aus
"schliesst zu frueh" ein "schliesst nie" gemacht -- und das waere
schlimmer, weil man dann gar nicht mehr herauskommt. */
await seite.evaluate(() => document.getElementById("f-art")
?.closest(".wahl2")?.querySelector(".wahl2__knopf")?.click());
await seite.waitForTimeout(300);
const vorEsc = await seite.evaluate(() => !!document.querySelector(".wahl2__liste"));
await seite.keyboard.press("Escape");
await seite.waitForTimeout(300);
const nachEsc = await seite.evaluate(() => !!document.querySelector(".wahl2__liste"));
ok(vorEsc && !nachEsc,
`Gegenprobe: Escape schliesst sie weiterhin (offen ${vorEsc} -> ${nachEsc})`);
await kontext.close();
}
} finally { } finally {
await browser.close(); await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+817
View File
@@ -0,0 +1,817 @@
/* =====================================================================
MEHRERE BILDER IM SUPPORT — IM ECHTEN BROWSER (02.10.2026)
=====================================================================
VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufügen bei einer
Meldung. 2-3 wären besser." Und in der zweiten Runde der Satz, auf
den es ankommt:
„wenn man es nacheinander versucht hinzuzufügen wird das Bild
immer nur ersetzt."
WARUM `pruef-support` DAFÜR NICHT REICHT. Die dortigen Prüfungen
sprechen mit dem Server und bekommen drei Bilder zurück -- grün,
und trotzdem wäre VanVans Problem unverändert da. Denn „es wird
nur ersetzt" passiert NICHT am Server, sondern in `support.js`:
Eine neue Auswahl im Dateifeld wirft die vorige weg, und zwar auch
dann, wenn das Feld `multiple` trägt. Ein Server, der drei Bilder
annimmt, hilft niemandem, dem der Browser nur eines mitgibt.
Also wird hier das gemessen, was SIE tut: dreimal hintereinander
ein Bild wählen. Und einmal viermal, damit die Grenze nicht nur im
Quelltext steht.
DREI AUSGÄNGE. Steht Playwright nicht bereit, endet die Prüfung mit
2 und sagt, was fehlt -- „konnte nicht nachsehen" ist etwas anderes
als „in Ordnung".
Aufrufen mit: node server/pruef-support-bilder.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-supbild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
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";
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 });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(300_000, "pruef-support-bilder");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const BASIS = `http://127.0.0.1:${PORT}`;
let geprueft = 0, fehler = 0;
function ok(bedingung, text) {
geprueft++;
if (bedingung) { console.log(" ok " + text); return true; }
fehler++; console.log(" FEHL " + text); return false;
}
const melde = (t) => console.log(t);
/* ---- Zugänge ------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Miss", "modi", "CODE-MODI-0001");
d.close();
async function keksFuer(rolle, code) {
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
return (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const keksModi = await keksFuer("modi", "CODE-MODI-0001");
const keksHand = await keksFuer("admin", "CODE-DOGI-0001");
/* ---- Playwright, oder der dritte Ausgang -------------------------- */
let pw = null;
try {
pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch (f) {
console.log("KONNTE NICHT NACHSEHEN: Playwright fehlt (" + (f?.message || "") + ").");
console.log(" Diese Pruefung braucht einen echten Browser -- was VanVan gemeldet hat,");
console.log(" passiert im Dateifeld und ist ueber die Schnittstelle nicht zu sehen.");
console.log(" Nachinstallieren in C:/Users/qciga/Documents/Obelix/Analyse:");
console.log(" npm i -D playwright && npx playwright install chromium");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
const browser = await pw.chromium.launch();
/** Drei verschiedene Bilder -- verschieden LANG und verschieden GROSS.
*
* Drei gleiche waeren nicht zu unterscheiden: Wuerde die Oberflaeche
* dreimal dasselbe anzeigen oder die Reihenfolge vertauschen, faellt
* das nur auf, wenn die Bilder sich unterscheiden. */
const BILDER = [
{ name: "eins.png", typ: "image/png", b64:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" },
{ name: "zwei.gif", typ: "image/gif", b64:
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" },
{ name: "drei.webp", typ: "image/webp", b64:
"UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==" },
];
/** Eine Datei in ein `<input type=file>` legen -- SO WIE EIN MENSCH.
*
* `setInputFiles` von Playwright ersetzt die ganze Auswahl und
* feuert `change` -- also genau das, was auch der Dateidialog des
* Browsers tut. Mehrmals aufgerufen ist es genau VanVans Weg:
* „nacheinander versucht hinzuzufuegen". */
async function waehle(seite, wahl, bilder) {
await seite.setInputFiles(wahl, bilder.map((b) => ({
name: b.name, mimeType: b.typ, buffer: Buffer.from(b.b64, "base64"),
})));
await seite.waitForTimeout(120);
}
async function neueSeite(breite, keks) {
const kontext = await browser.newContext({
viewport: { width: breite, height: 900 }, hasTouch: breite <= 860,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
seite.on("pageerror", (f) => { console.log(" JS-FEHLER: " + f.message); fehler++; });
await seite.goto(BASIS + "/workspace/support.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(500);
return { kontext, seite };
}
/* =====================================================================
1. NACHEINANDER WAEHLEN LEGT DAZU — VanVans Satz, gemessen
===================================================================== */
melde("\n=== Nacheinander waehlen (VanVan, Meldung #11, Runde 2) ===");
{
const { kontext, seite } = await neueSeite(390, keksModi);
const zahl = () => seite.locator("#vorschau .s-vorschau__eins").count();
ok(await zahl() === 0, `vorher ist nichts gewaehlt (${await zahl()})`);
/* ==== UND DIE SEITE SAGT, DASS MEHRERE GEHEN (03.10.2026) ======
Gefunden auf Filipes Frage „koennen die leute auch schon mehrere
fotos schicken?" -- technisch ja, seit dem 02.10. Nur sagte es
niemand: Der Knopf hiess „Bild anhaengen" (Einzahl), die
Unterzeile „haeng ein Bild dran", und daneben stand nichts.
Eine Moeglichkeit, von der man nichts weiss, gibt es nicht. Wer
nicht zufaellig ein zweites Mal tippt, schickt eines -- und aus
seiner Sicht waere VanVans Meldung ungeloest. */
const sagt = await seite.evaluate(() => ({
knopf: document.getElementById('bild-waehlen')?.textContent.trim() || '',
daneben: document.getElementById('bild-name')?.textContent.trim() || '',
unter: document.getElementById('seiten-unter')?.textContent.replace(/\s+/g, ' ') || '',
}));
ok(/Bilder/.test(sagt.knopf), `der Knopf steht in der Mehrzahl („${sagt.knopf}")`);
ok(/bis zu 3 Bilder/.test(sagt.daneben),
`und daneben steht, wie viele gehen („${sagt.daneben}")`);
ok(!/ein Bild dran/.test(sagt.unter),
`auch die Unterzeile sagt nicht mehr „ein Bild" („${sagt.unter.slice(0, 60)}…")`);
await waehle(seite, "#bild", [BILDER[0]]);
const n1 = await zahl();
ok(n1 === 1, `nach dem ersten Bild: eine Vorschau (${n1})`);
/* UND DASS NOCH PLATZ IST. „eins.png · 0 KB" allein sagt nicht,
dass ein zweites geht -- genau an dieser Stelle hoert jemand
auf. */
const nachEinem = await seite.locator('#bild-name').textContent();
ok(/noch 2 möglich/.test(nachEinem || ''),
` und daneben steht, dass noch Platz ist („${(nachEinem || '').trim()}")`);
/* HIER WAR DER FEHLER. Vorher stand die Auswahl in einer einzigen
Variablen -- der zweite Griff warf den ersten weg, und genau das
hat VanVan beschrieben. */
await waehle(seite, "#bild", [BILDER[1]]);
const n2 = await zahl();
ok(n2 === 2, `nach dem ZWEITEN: zwei (${n2}) — vorher wurde hier ersetzt`);
await waehle(seite, "#bild", [BILDER[2]]);
const n3 = await zahl();
ok(n3 === 3, `nach dem dritten: drei (${n3})`);
/* DIE REIHENFOLGE BLEIBT, und es sind wirklich die drei. Abgelesen
am Alternativtext, in dem der Dateiname steht. */
const texte = await seite.locator("#vorschau img").evaluateAll(
(bilder) => bilder.map((b) => b.alt));
ok(texte.length === 3 && /eins\.png/.test(texte[0])
&& /zwei\.gif/.test(texte[1]) && /drei\.webp/.test(texte[2]),
`und zwar in der Reihenfolge des Waehlens (${texte.join(", ")})`);
/* ---- DIE GRENZE ----------------------------------------------- */
await waehle(seite, "#bild", [{ ...BILDER[0], name: "vier.png" }]);
const n4 = await zahl();
ok(n4 === 3, `ein viertes kommt nicht dazu (${n4})`);
const hinweis = (await seite.locator("#melden-fehler").textContent() || "").trim();
ok(/3 Bilder|drei Bilder/i.test(hinweis) && hinweis.length > 10,
`und es steht dabei, warum („${hinweis}")`);
/* ---- DASSELBE BILD ZWEIMAL IST EINES --------------------------- */
const vorher = await zahl();
await waehle(seite, "#bild", [BILDER[0]]);
ok(await zahl() === vorher,
`dasselbe Bild noch einmal zaehlt nicht doppelt (${await zahl()})`);
/* ==== UND DREI AUF EINEN GRIFF (03.10.2026) ====================
VanVans Satz hat ZWEI Haelften, und ich hatte nur eine gemessen:
„man kann nicht mehrere Bilder zum hinzufuegen AUSWAEHLEN.
Und wenn man es NACHEINANDER versucht hinzuzufuegen wird das
Bild immer nur ersetzt"
Oben steht das Nacheinander. Hier steht das Auswaehlen: drei
Dateien in EINEM Griff, so wie der Dateidialog sie uebergibt,
wenn man sie mit gedrueckter Taste markiert. Das haengt am
`multiple` im Feld -- und ohne diese Zeile waere es nur eine
Behauptung im HTML.
ERST ALLES WEG, dann waehlen: Sonst misst die Zeile, was vorher
schon dastand. */
await seite.locator('#bild-weg').click();
await seite.waitForTimeout(120);
await waehle(seite, "#bild", BILDER);
const aufEinmal = await seite.locator("#vorschau img").evaluateAll(
(bilder) => bilder.map((b) => b.alt));
ok(aufEinmal.length === 3,
`drei auf einen Griff ausgewaehlt ergeben drei (${aufEinmal.length})`);
ok(/eins\.png/.test(aufEinmal[0] || "") && /drei\.webp/.test(aufEinmal[2] || ""),
` und zwar in der Reihenfolge des Dialogs (${aufEinmal.join(", ")})`);
/* GEGENPROBE ZUM `multiple`: Ohne das Merkmal gaebe der Browser nur
EINE Datei weiter, egal wie viele man markiert. Gemessen wird
deshalb das Merkmal selbst -- sonst stuende hier eine Drei, die
auch aus drei einzelnen Griffen stammen koennte. */
ok(await seite.locator("#bild").evaluate((f) => f.multiple) === true,
" das Feld laesst Mehrfachauswahl ausdruecklich zu (multiple)");
/* ---- EINZELN WIEDER WEGNEHMEN ---------------------------------- */
await seite.locator("#vorschau .s-vorschau__eins").nth(1)
.locator(".s-vorschau__weg").click();
await seite.waitForTimeout(120);
const nachWeg = await seite.locator("#vorschau img").evaluateAll(
(bilder) => bilder.map((b) => b.alt));
ok(nachWeg.length === 2 && /eins\.png/.test(nachWeg[0]) && /drei\.webp/.test(nachWeg[1]),
`das mittlere einzeln entfernen laesst die anderen stehen (${nachWeg.join(", ")})`);
/* ---- UND „ALLE WEG" NIMMT ALLE ---------------------------------- */
await seite.locator("#bild-weg").click();
await seite.waitForTimeout(120);
ok(await zahl() === 0, `„Alle weg" raeumt auf (${await zahl()})`);
ok(await seite.locator("#vorschau").isHidden(),
"und der leere Kasten nimmt keinen Platz mehr ein");
await kontext.close();
}
/* =====================================================================
2. DIE KREUZE SIND ZU TREFFEN — 44 px und nichts davor
===================================================================== */
melde("\n=== Die Kreuze auf 390 px ===");
{
const { kontext, seite } = await neueSeite(390, keksModi);
for (const b of BILDER) await waehle(seite, "#bild", [b]);
const mass = await seite.evaluate(() => {
const raus = [];
for (const k of document.querySelectorAll("#vorschau .s-vorschau__weg")) {
const r = k.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
raus.push({
breite: Math.round(r.width), hoehe: Math.round(r.height),
frei: k.contains(oben) || k === oben,
imBild: r.right <= document.documentElement.clientWidth + 0.5 && r.left >= -0.5,
});
}
return raus;
});
ok(mass.length === 3, `drei Kreuze da (${mass.length})`);
const zuKlein = mass.filter((m) => m.breite < 44 || m.hoehe < 44);
ok(mass.length === 3 && zuKlein.length === 0,
`jedes misst mindestens 44x44 (${mass.map((m) => `${m.breite}x${m.hoehe}`).join(", ")})`);
const verdeckt = mass.filter((m) => !m.frei);
ok(mass.length === 3 && verdeckt.length === 0,
`und keines liegt unter etwas anderem (${verdeckt.length} verdeckt)`);
const draussen = mass.filter((m) => !m.imBild);
ok(mass.length === 3 && draussen.length === 0,
`alle drei stehen im sichtbaren Bereich (${draussen.length} daneben)`);
/* GEGENPROBE: Kann diese Messung ueberhaupt „verdeckt" sagen? Ein
Deckel ueber das erste Kreuz, und sie MUSS anschlagen -- sonst
waere der gruene Haken oben wertlos. */
await seite.evaluate(() => {
const k = document.querySelector("#vorschau .s-vorschau__weg");
const r = k.getBoundingClientRect();
const deckel = document.createElement("div");
deckel.id = "probe-deckel";
deckel.style.cssText = `position:fixed;left:${r.left}px;top:${r.top}px;`
+ `width:${r.width}px;height:${r.height}px;z-index:9999;`;
document.body.append(deckel);
});
const mitDeckel = await seite.evaluate(() => {
const k = document.querySelector("#vorschau .s-vorschau__weg");
const r = k.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return k.contains(oben) || k === oben;
});
ok(mitDeckel === false, "Gegenprobe: ein Deckel darueber wird erkannt");
await seite.evaluate(() => document.getElementById("probe-deckel")?.remove());
/* ---- UND NICHTS RAGT AUS DER SEITE -------------------------- */
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `die Seite mit drei Vorschauen ist nicht zu breit (${ueber} px)`);
await kontext.close();
}
/* =====================================================================
3. ABSCHICKEN UND WIEDERSEHEN
===================================================================== */
melde("\n=== Abschicken und wiedersehen ===");
let meldungId = 0;
{
const { kontext, seite } = await neueSeite(390, keksModi);
await seite.fill("#text", "Vorher, der Klick und nachher – drei Bilder dazu.");
for (const b of BILDER) await waehle(seite, "#bild", [b]);
await seite.click("#senden");
await seite.waitForTimeout(1200);
const bilder = await seite.locator("#meine-liste .s-karte__bild img").count();
ok(bilder === 3, `die eigene Karte zeigt alle drei (${bilder})`);
/* GELADEN HEISST GELADEN. Ein <img> mit kaputter Adresse steht
genauso im Baum wie ein gutes -- `naturalWidth` ist der
Unterschied. Ohne diese Zeile waeren drei 404er gruen. */
await seite.waitForTimeout(600);
const echt = await seite.locator("#meine-liste .s-karte__bild img").evaluateAll(
(bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
ok(echt === 3, `und alle drei kommen wirklich vom Server (${echt} geladen)`);
const wege = await seite.locator("#meine-liste .s-karte__bild a").evaluateAll(
(as) => as.map((a) => a.getAttribute("href")));
ok(new Set(wege).size === 3,
`drei verschiedene Adressen, nicht dreimal dieselbe (${new Set(wege).size})`);
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `die Karte mit drei Bildern ist nicht zu breit (${ueber} px)`);
/* Das Feld ist danach leer -- sonst haengt beim naechsten Melden
noch die alte Auswahl dran. */
ok(await seite.locator("#vorschau .s-vorschau__eins").count() === 0,
"nach dem Abschicken ist die Auswahl leer");
meldungId = await seite.evaluate(() => {
const k = document.querySelector("#meine-liste .s-karte");
return Number((k?.id || "").replace("meldung-", "")) || 0;
});
ok(meldungId > 0, `die Meldung hat eine Nummer (#${meldungId})`);
await kontext.close();
}
/* =====================================================================
4. UND IM „NOCH NICHT"-FENSTER GENAUSO
===================================================================== */
melde("\n=== Bilder in der Rueckfrage ===");
{
/* Die Leitung schickt die Meldung zum Melder zurueck -- erst dann
gibt es die zwei Knoepfe. */
await fetch(BASIS + `/workspace/api/support/${meldungId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: keksHand },
body: JSON.stringify({ stand: "erledigt", antwort: "Sollte jetzt gehen." }),
});
const { kontext, seite } = await neueSeite(390, keksModi);
await seite.locator("#meine-liste .s-tun--geht").waitFor({ timeout: 8000 });
await seite.locator("#meine-liste .s-karte__tun .schritt:not(.s-tun--geht)").click();
await seite.locator("dialog.nachfrage").waitFor({ state: "visible", timeout: 8000 });
ok(true, "das Fenster „Geht es noch nicht?“ geht auf");
const dz = () => seite.locator("dialog.nachfrage .nachfrage__vorschau").count();
ok(await dz() === 0, `vorher keine Vorschau (${await dz()})`);
await waehle(seite, "#nachfrage-bild", [BILDER[0]]);
ok(await dz() === 1, `ein Bild (${await dz()})`);
await waehle(seite, "#nachfrage-bild", [BILDER[1]]);
ok(await dz() === 2, `nacheinander gewaehlt macht zwei (${await dz()}) — auch hier`);
await waehle(seite, "#nachfrage-bild", [BILDER[2]]);
await waehle(seite, "#nachfrage-bild", [{ ...BILDER[1], name: "vier.gif" }]);
ok(await dz() === 3, `und bei drei ist Schluss (${await dz()})`);
await seite.fill("#nachfrage-grund", "Geht noch nicht, hier drei Bildschirmfotos.");
await seite.locator("dialog.nachfrage [data-teil=\"ja\"]").click();
await seite.waitForTimeout(1500);
const imVerlauf = await seite.locator("#meine-liste .s-runde .s-karte__bild img").count();
ok(imVerlauf === 3, `im Verlauf haengen alle drei an der Runde (${imVerlauf})`);
const echt = await seite.locator("#meine-liste .s-runde .s-karte__bild img").evaluateAll(
(bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
ok(echt === 3, `und alle drei laden (${echt})`);
/* DIE BILDER DER MELDUNG BLEIBEN OBEN, die der Antwort unten. Sonst
waere nach drei Runden nicht mehr zu sehen, was wozu gehoert. */
const oben = await seite.locator("#meine-liste .s-karte > .s-karte__bild img").count();
ok(oben === 3, `oben stehen weiterhin die drei vom Melden (${oben})`);
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `und die Seite bleibt schmal genug (${ueber} px)`);
await kontext.close();
}
/* =====================================================================
5. AUF DEM GROSSEN BILDSCHIRM: KEINE LEERE SPALTE
=====================================================================
Ab 760 px steht das Bild der Meldung NEBEN dem Text. Seit die
Rundenbilder denselben Kasten benutzen, trifft diese Regel ohne den
Pfeil in `:has(> …)` auch auf eine Karte zu, deren einziges Bild
tief im Verlauf steckt -- die Karte bekaeme eine zweite Spalte, in
der nichts steht.
===================================================================== */
melde("\n=== Die zweite Spalte nur mit eigenem Bild ===");
{
/* Eine Meldung OHNE Bild, aber mit einem Bild in der Antwortrunde. */
const neu = await fetch(BASIS + "/workspace/api/support", {
method: "POST",
headers: { cookie: keksModi, "Content-Type": "text/plain",
"x-text": encodeURIComponent("Diese Meldung hat selbst kein Bild.") },
});
const id2 = (await neu.json())?.meldung?.id;
await fetch(BASIS + `/workspace/api/support/${id2}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: keksHand },
body: JSON.stringify({ stand: "erledigt", antwort: "Schau mal." }),
});
await fetch(BASIS + `/workspace/api/support/${id2}/rueckmeldung`, {
method: "POST",
headers: { cookie: keksModi, "Content-Type": "application/octet-stream",
"x-geht": "nein", "x-text": encodeURIComponent("Geht noch nicht, siehe Bild.") },
body: Buffer.from(BILDER[0].b64, "base64"),
});
const { kontext, seite } = await neueSeite(1280, keksModi);
const mass = await seite.evaluate((nr) => {
const k = document.getElementById("meldung-" + nr);
if (!k) return null;
const st = getComputedStyle(k);
const text = k.querySelector(".s-karte__text");
return {
raster: st.display,
spalten: st.gridTemplateColumns,
textBreite: Math.round(text?.getBoundingClientRect().width || 0),
karteBreite: Math.round(k.getBoundingClientRect().width),
};
}, id2);
ok(!!mass, `die Karte ohne eigenes Bild ist da (#${id2})`);
/* GEMESSEN WIRD `display`, NICHT DIE TEXTBREITE. Erst stand hier
„der Text nimmt mehr als 90 % ein" -- und die Gegenprobe darunter
wurde rot, obwohl alles stimmte: Die Pruefbilder sind 1x1 Bildpunkt
gross, die Bildspalte damit wenige Pixel breit, und der Text nimmt
auch MIT zweiter Spalte 93 %. Die Zahl mass die Bildgroesse, nicht
die Regel. `display` sagt genau das, was die Regel schaltet. */
ok(mass?.raster !== "grid",
`und sie bekommt KEINE zweite Spalte (display: ${mass?.raster})`);
/* GEGENPROBE: Bei einer Karte MIT eigenem Bild soll die zweite
Spalte sehr wohl kommen -- sonst waere die Zeile darueber nur
deshalb gruen, weil die Regel gar nicht mehr wirkt. */
const mitBild = await seite.evaluate((nr) => {
const k = document.getElementById("meldung-" + nr);
if (!k) return null;
return {
display: getComputedStyle(k).display,
spalten: getComputedStyle(k).gridTemplateColumns,
eigene: k.querySelectorAll(":scope > .s-karte__bild img").length,
imVerlauf: k.querySelectorAll(".s-runde .s-karte__bild img").length,
};
}, meldungId);
ok(mitBild?.display === "grid" && mitBild.eigene === 3,
`Gegenprobe: MIT eigenem Bild gibt es die zweite Spalte `
+ `(${mitBild?.display}, ${mitBild?.eigene} eigene Bilder)`);
/* UND BEIDE KARTEN HABEN BILDER IM VERLAUF -- die eine mit, die
andere ohne eigenes. Ohne diese Zeile koennte der Unterschied
oben auch daher ruehren, dass die zweite Karte ueberhaupt kein
Bild hat. */
ok((mitBild?.imVerlauf || 0) === 3,
` und beide Karten haben Rundenbilder (${mitBild?.imVerlauf})`);
await kontext.close();
}
/* =====================================================================
6. DER VERLAUF: EINE FARBE JE RUNDE (03.10.2026)
=====================================================================
Filipe: „ich will diese komplette seite vom support viel
uebersichtlicher, profissioneller, jede runde soll auch immer eine
spezielle farbe haben und anders aufgestellt aufgeteilt sein damit
man einen viel besseren und krasseren ueberblick hat."
WARUM DAS GEPRUEFT WERDEN MUSS und nicht nur auf einem Bild steht:
Die Farbe kommt aus einer Rechnung (`rundenTon` in support.js) und
wird im CSS zu `hsl()`. Zwischen diesen beiden Stellen kann sie
verlorengehen, ohne dass etwas abstuerzt -- dann sind alle Runden
gleich grau, und auf dem Bildschirm sieht das aus wie Absicht.
GEMESSEN WIRD DIE ANGEZEIGTE FARBE, nicht die Zahl: `--ton` kann
gesetzt sein und das CSS sie trotzdem nicht benutzen.
===================================================================== */
melde("\n=== Eine Farbe je Runde ===");
{
/* Eine Meldung mit VIER Runden -- so viele, wie es Toene gibt.
Mit zwei saehe man zwei Farben und wueste nicht, ob es eine
Reihe ist. */
/* UEBER `fetch` MIT KEKS -- diese Datei hat keinen `hol`-Helfer wie
pruef-support.mjs. Mein erster Anlauf rief ihn trotzdem auf und
stuerzte ab; die Pruefung hat es in derselben Minute gesagt. */
const ruf = (pfad, keks, o = {}) => fetch(BASIS + pfad,
{ ...o, headers: { cookie: keks, ...(o.headers || {}) } });
const neu = await ruf("/workspace/api/support", keksModi, {
method: "POST", headers: { "Content-Type": "text/plain",
"x-text": encodeURIComponent("Probe fuer die Rundenfarben.") },
});
const vId = (await neu.json())?.meldung?.id;
for (let i = 0; i < 4; i++) {
await ruf(`/workspace/api/support/${vId}`, keksHand, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stand: "erledigt", antwort: `Versuch ${i + 1}.` }),
});
await ruf(`/workspace/api/support/${vId}/rueckmeldung`, keksModi, {
method: "POST",
headers: { "x-geht": "nein",
"x-text": encodeURIComponent(`Ging noch nicht, Versuch ${i + 1}.`) },
});
}
/* Noch einmal zurueckgeben, damit die letzte Runde eine Antwort
hat und der Melder wieder dran ist. */
await ruf(`/workspace/api/support/${vId}`, keksHand, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stand: "erledigt", antwort: "Jetzt aber." }),
});
const { kontext, seite } = await neueSeite(1280, keksModi);
const karte = `#meldung-${vId}`;
await seite.locator(karte).waitFor({ timeout: 8000 });
const bild = await seite.evaluate((wahl) => {
const k = document.querySelector(wahl);
const punkte = [...k.querySelectorAll(".s-rundenleiste__punkt")];
const offen = [...k.querySelectorAll(".s-runde")];
const farbe = (e) => getComputedStyle(e).backgroundColor;
return {
punkte: punkte.length,
punktFarben: punkte.map((p) => farbe(p)),
nummern: punkte.map((p) => p.textContent.trim()),
/* Die Farbe der Rundennummer -- sie muss sich mit der Runde
aendern, sonst ist die Achse farbig und die Schrift nicht. */
nrFarben: offen.map((r) =>
getComputedStyle(r.querySelector(".s-runde__nr")).color),
offene: offen.length,
mehr: (k.querySelector(".s-verlauf__mehr")?.innerText || "")
.replace(/\s+/g, " ").trim(),
/* Zwei Sprechrichtungen: Wer geantwortet hat und was daraus
wurde, muessen verschieden aussehen. */
schilder: [...k.querySelectorAll(".s-schritt__schild")]
.map((x) => x.textContent.trim()),
hoch: Math.round(k.getBoundingClientRect().height),
};
}, karte);
ok(bild.punkte === 5, `die Leiste zeigt jede Runde (${bild.punkte} Punkte)`);
ok(bild.nummern.join(",") === "1,2,3,4,5",
`mit ihrer Nummer (${bild.nummern.join(", ")})`);
/* DER EIGENTLICHE PUNKT: vier verschiedene Farben. Die fuenfte
wiederholt die erste -- so ist die Formel gebaut, und das steht
hier als Aussage, damit es niemand fuer einen Fehler haelt. */
const verschieden = new Set(bild.punktFarben.slice(0, 4)).size;
ok(verschieden === 4,
`die ersten vier Runden haben vier verschiedene Farben (${verschieden})`);
ok(bild.punktFarben[4] === bild.punktFarben[0],
"und die fuenfte faengt die Reihe wieder von vorn an — so ist die Formel");
/* GEGENPROBE: Waere `--ton` nicht angekommen, waeren ALLE gleich.
Diese Zeile unterscheidet „vier Farben" von „viermal dieselbe". */
ok(new Set(bild.punktFarben).size > 1,
`Gegenprobe: sie sind nicht alle gleich (${new Set(bild.punktFarben).size} Farben)`);
ok(new Set(bild.nrFarben).size === bild.offene,
`auch die Rundennummern tragen ihre Farbe (${new Set(bild.nrFarben).size} `
+ `von ${bild.offene})`);
ok(bild.offene === 2, `offen stehen die letzten zwei Runden (${bild.offene})`);
ok(/3 frühere Runden/.test(bild.mehr),
`und die aelteren sind einen Griff entfernt („${bild.mehr}")`);
ok(bild.schilder.includes("Geantwortet")
&& bild.schilder.some((t) => /Ging noch nicht|Wartet/.test(t)),
`jede Runde zeigt beide Seiten (${[...new Set(bild.schilder)].join(" / ")})`);
/* AUFKLAPPEN UND WIEDER ZU. */
await seite.locator(`${karte} .s-verlauf__mehr`).click();
await seite.waitForTimeout(400);
const ganz = await seite.evaluate((wahl) => ({
offene: document.querySelectorAll(`${wahl} .s-runde`).length,
mehr: (document.querySelector(`${wahl} .s-verlauf__mehr`)?.innerText || "")
.replace(/\s+/g, " ").trim(),
}), karte);
ok(ganz.offene === 5, `aufgeklappt stehen alle da (${ganz.offene})`);
await seite.locator(`${karte} .s-verlauf__mehr`).click();
await seite.waitForTimeout(400);
ok(await seite.locator(`${karte} .s-runde`).count() === 2,
"und wieder zu — der Knopf geht in beide Richtungen");
/* UND NICHTS RAGT HERAUS, auch nicht auf dem Handy. */
await kontext.close();
const h = await neueSeite(390, keksModi);
await h.seite.locator(karte).waitFor({ timeout: 8000 });
const ueber = await h.seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `auf 390 px ragt nichts heraus (${ueber} px)`);
/* GEGENPROBE ZUR LEISTE: Bei EINER Runde gibt es sie nicht -- ein
Punkt neben keinem Punkt sagt nichts. */
const einzeln = await h.seite.evaluate(() => {
const k = [...document.querySelectorAll(".s-karte")]
.find((x) => x.querySelectorAll(".s-runde").length === 1);
return k ? k.querySelectorAll(".s-rundenleiste").length : -1;
});
ok(einzeln === 0 || einzeln === -1,
`bei einer einzigen Runde gibt es keine Leiste (${einzeln})`);
await h.kontext.close();
}
/* =====================================================================
7. SIND DIE TEXTE IN DER KARTE ZU LESEN? (03.10.2026)
=====================================================================
Filipe: „nur die kacheln noch viel geiler. so dass ich die texte
auch besser erkenne."
GEMESSEN, BEVOR ETWAS ANGEFASST WURDE -- und die naheliegende
Vermutung war falsch:
Kontrast aller zehn Textarten 7,5 bis 15,7 : 1
ZEICHEN JE ZEILE 147
Am Kontrast lag es nicht. Es lag an der Zeilenlaenge: Auf 1280 px
lief der Meldungstext ueber 1120 px, also knapp 150 Zeichen.
Angenehm sind 45 bis 75; darueber findet das Auge beim
Zeilenwechsel die naechste Zeile nicht mehr sicher.
ZWEI ZAHLEN WERDEN DESHALB GEPRUEFT, und beide mussten vorher
gemessen werden, um sie ueberhaupt zu kennen. Eine Regel, die aus
einer Messung kommt, ist etwas anderes als eine, die man sich
ausdenkt.
===================================================================== */
melde("\n=== Sind die Texte zu lesen? ===");
{
const { kontext, seite } = await neueSeite(1280, keksModi);
await seite.locator(".s-karte__text").first().waitFor({ timeout: 8000 });
const lesbar = await seite.evaluate(() => {
/* ==== ZWEI SCHREIBWEISEN, EINE EINHEIT ======================
`rgb()` zaehlt 0..255, `color(srgb …)` zaehlt 0..1 -- und
genau das liefert Chrome fuer jedes `color-mix()`. Beim Bauen
dieser Messung hat mich das getaeuscht: 0,77 als 0,77/255
gelesen ergibt fast Schwarz, und drei gut lesbare Texte
standen mit Kontrast 1,04 da. Fast haette ich Farben
repariert, die in Ordnung sind. */
const zahl = (t) => {
const n = (t.match(/[\d.]+/g) || []).map(Number);
if (/^color\(/.test(t)) {
return [n[0] * 255, n[1] * 255, n[2] * 255, n.length > 3 ? n[3] : 1];
}
return n;
};
const kanal = (c) => {
const x = c / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
};
const hell = ([r, g, b]) => 0.2126 * kanal(r) + 0.7152 * kanal(g) + 0.0722 * kanal(b);
const mischen = (v, h, a) => v.map((x, i) => x * a + h[i] * (1 - a));
/* DER GRUND WIRD NACH OBEN GESUCHT, bis eine undurchsichtige
Farbe kommt -- halbdurchsichtige Kaesten uebereinander sind
sonst nicht zu rechnen. */
const grundVon = (e) => {
let n = e; const stapel = [];
while (n && n !== document.documentElement) {
const bg = zahl(getComputedStyle(n).backgroundColor);
const a = bg.length === 4 ? bg[3] : 1;
if (a > 0) stapel.push([bg.slice(0, 3), a]);
if (a === 1) break;
n = n.parentElement;
}
let farbe = [11, 15, 22];
for (let i = stapel.length - 1; i >= 0; i--) {
farbe = mischen(stapel[i][0], farbe, stapel[i][1]);
}
return farbe;
};
const kontrast = (e) => {
const vg = zahl(getComputedStyle(e).color);
const a = vg.length === 4 ? vg[3] : 1;
const grund = grundVon(e);
const vorne = mischen(vg.slice(0, 3), grund, a);
const l1 = hell(vorne), l2 = hell(grund);
const [h, d] = l1 > l2 ? [l1, l2] : [l2, l1];
return (h + 0.05) / (d + 0.05);
};
const arten = [".s-karte__text", ".s-marke", ".s-karte__zeit", ".s-runde__nr",
".s-runde__von", ".s-runde__zeit", ".s-schritt__schild", ".s-schritt__text",
".s-rundenleiste__punkt"];
const werte = [];
for (const w of arten) {
const e = document.querySelector(w);
if (!e) continue;
const st = getComputedStyle(e);
const px = parseFloat(st.fontSize);
werte.push({
w, px: Math.round(px * 100) / 100,
k: Math.round(kontrast(e) * 100) / 100,
zeichen: Math.round(e.getBoundingClientRect().width / (px * 0.5)),
gross: px >= 24 || (px >= 18.66 && Number(st.fontWeight) >= 700),
});
}
/* GEGENPROBE IM BROWSER: Ein absichtlich zu blasser Text MUSS
auffallen. Ohne sie waere „alle ueber 4,5" auch dann gruen,
wenn die Rechnung kaputt ist -- und genau das war sie. */
const probe = document.createElement("p");
probe.textContent = "Probe";
probe.style.color = "#17202c";
document.querySelector(".s-karte").append(probe);
const blass = Math.round(kontrast(probe) * 100) / 100;
probe.remove();
return { werte, blass };
});
ok(lesbar.werte.length >= 8,
`${lesbar.werte.length} Textarten in der Karte gemessen`);
const text = lesbar.werte.find((x) => x.w === ".s-karte__text");
/* DER MELDUNGSTEXT IST DIE HAUPTSACHE DER SEITE. */
ok(text && text.px >= 16,
`der Meldungstext ist ${text?.px} px gross (mindestens 16)`);
ok(text && text.zeichen <= 85,
`und bricht nach etwa ${text?.zeichen} Zeichen um (hoechstens 85, `
+ "vorher waren es 147)");
const verlauf = lesbar.werte.find((x) => x.w === ".s-schritt__text");
ok(verlauf && verlauf.px >= 15 && verlauf.zeichen <= 85,
`der Verlaufstext: ${verlauf?.px} px, etwa ${verlauf?.zeichen} Zeichen`);
/* UND KEIN TEXT UNTER DER KONTRASTSCHWELLE. */
const schwach = lesbar.werte.filter((x) => x.k < (x.gross ? 3 : 4.5));
ok(schwach.length === 0,
`jeder Text haelt die Kontrastschwelle (${schwach.length} darunter`
+ (schwach.length ? `: ${schwach.map((x) => `${x.w} ${x.k}`).join(", ")}` : "")
+ `) — schwaechster ${Math.min(...lesbar.werte.map((x) => x.k))} : 1`);
ok(lesbar.blass < 4.5,
`Gegenprobe: ein absichtlich blasser Text faellt auf (${lesbar.blass} : 1)`);
/* UND AUF DEM HANDY: kurze Zeilen sind dort von selbst kurz -- die
Frage ist, ob die groessere Schrift die Seite sprengt. */
await kontext.close();
const h = await neueSeite(390, keksModi);
await h.seite.locator(".s-karte__text").first().waitFor({ timeout: 8000 });
const handy = await h.seite.evaluate(() => {
const e = document.querySelector(".s-karte__text");
const px = parseFloat(getComputedStyle(e).fontSize);
return {
px: Math.round(px * 100) / 100,
zeichen: Math.round(e.getBoundingClientRect().width / (px * 0.5)),
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
ok(handy.ueber <= 0,
`auf 390 px ragt mit der groesseren Schrift nichts heraus (${handy.ueber} px)`);
ok(handy.zeichen >= 25 && handy.zeichen <= 60,
`und die Zeile bleibt lesbar lang (etwa ${handy.zeichen} Zeichen)`);
await h.kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
process.exit(fehler ? 1 : 0);
+380 -17
View File
@@ -158,6 +158,20 @@ const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
/** Noch zwei Bilder -- und zwar mit ABSICHTLICH VERSCHIEDENEN LAENGEN
* (gemessen: PNG 70, GIF 42, WebP 34 Bytes).
*
* Drei gleich lange Bilder wuerden die Zerlegung des Rumpfes nicht
* pruefen: Waere die Reihenfolge der Laengen vertauscht oder die
* Schnittstelle um ein Byte verschoben, kaeme trotzdem etwas
* Bildaehnliches heraus. Mit 70/42/34 faellt jeder Fehlschnitt auf,
* weil das Ergebnis dann weder erkannt wird noch die richtige Zahl
* Bytes hat. Drei verschiedene Formate pruefen nebenbei, dass der
* Typ je Bild aus SEINEN Kopfbytes kommt und nicht aus dem
* Content-Type der ganzen Anfrage. */
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64");
const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64");
const kekse = {}; const kekse = {};
melde("\n=== Alle neun Rollen kommen herein ==="); melde("\n=== Alle neun Rollen kommen herein ===");
for (const [rolle] of ROLLEN) { for (const [rolle] of ROLLEN) {
@@ -279,6 +293,7 @@ melde("\n=== Die Seite ist fuer jede Rolle offen ===");
===================================================================== */ ===================================================================== */
melde("\n=== Melden ==="); melde("\n=== Melden ===");
let meldungMitBild = 0; let meldungMitBild = 0;
let bildWeg = "";
{ {
/* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */ /* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */
const leer = await hol("creator", "/workspace/api/support", { const leer = await hol("creator", "/workspace/api/support", {
@@ -305,8 +320,16 @@ let meldungMitBild = 0;
if (mitBild.status === 201) { if (mitBild.status === 201) {
const j = await mitBild.json(); const j = await mitBild.json();
meldungMitBild = j.meldung?.id || 0; meldungMitBild = j.meldung?.id || 0;
ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes"); bildWeg = j.meldung?.bilder?.[0]?.weg || "";
} else { ok(false, "kein Bild in der Antwort"); } /* EINE LISTE, AUCH BEI EINEM BILD (02.10.2026). Vorher stand hier
`j.meldung.bild` -- ein Feld fuer genau eines. Seit VanVans
Meldung #11 sind es bis zu drei, und ein Sonderfall „genau
eines" waere die zweite Auskunft auf dieselbe Frage. */
ok(Array.isArray(j.meldung?.bilder) && j.meldung.bilder.length === 1,
`und die Antwort nennt genau ein Bild (${j.meldung?.bilder?.length})`);
ok(/^\/workspace\/api\/support\/\d+\/bild\/\d+$/.test(bildWeg),
` mit seiner eigenen Nummer im Weg (${bildWeg})`);
} else { ok(false, "kein Bild in der Antwort"); ok(false, "kein Weg zum Bild"); }
/* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen. /* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen.
Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die
@@ -360,11 +383,11 @@ melde("\n=== Wer sieht was ===");
} }
/* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */ /* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */
const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`); const fremd = await hol("creator", bildWeg);
ok(fremd.status === 404, ok(fremd.status === 404,
`das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`); `das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`);
const eigen = await hol("modi", `/workspace/api/support/${meldungMitBild}/bild`); const eigen = await hol("modi", bildWeg);
ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`); ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`);
ok(eigen.headers.get("content-type") === "image/png", ok(eigen.headers.get("content-type") === "image/png",
`ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`); `ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`);
@@ -373,8 +396,22 @@ melde("\n=== Wer sieht was ===");
ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""), ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""),
"und einer Richtlinie, die jede Ausfuehrung nimmt"); "und einer Richtlinie, die jede Ausfuehrung nimmt");
const leitungSieht = await hol("hand", `/workspace/api/support/${meldungMitBild}/bild`); const leitungSieht = await hol("hand", bildWeg);
ok(leitungSieht.status === 200, "die Leitung sieht es auch"); ok(leitungSieht.status === 200, "die Leitung sieht es auch");
/* ==== DIE MELDUNGSNUMMER IM WEG IST DIE SICHTBARKEITSFRAGE =======
Der Weg lautet `/support/<meldung>/bild/<bild>`, und die
Bildnummer allein waere eindeutig. Trotzdem muss BEIDES
zusammenpassen -- sonst koennte man die eigene Meldungsnummer
vorn hinschreiben und hinten die Bildnummer einer fremden
durchprobieren. Gegenprobe: derselbe Bildweg, aber eine andere
Meldung davor. */
const bildNr = Number((bildWeg.match(/\/bild\/(\d+)$/) || [])[1] || 0);
const vertauscht = await hol("modi",
`/workspace/api/support/${meldungMitBild + 1}/bild/${bildNr}`);
ok(bildNr > 0 && vertauscht.status === 404,
`eine fremde Meldungsnummer vor der eigenen Bildnummer gibt 404 (${vertauscht.status})`);
} }
/* ===================================================================== /* =====================================================================
@@ -597,19 +634,25 @@ melde("\n=== Sich kuemmern ===");
const stand = await standVon("modi", neuId); const stand = await standVon("modi", neuId);
const r1 = (stand?.verlauf || [])[0]; const r1 = (stand?.verlauf || [])[0];
ok(!!r1?.urteil_bild, const r1weg = r1?.bilder?.[0]?.weg || "";
`im Verlauf haengt das Bild an Runde ${r1?.nr} (${r1?.urteil_bild})`); ok((r1?.bilder || []).length === 1,
`im Verlauf haengt das Bild an Runde ${r1?.nr} (${(r1?.bilder || []).length})`);
/* AN DER RUNDE UND NICHT AN DER MELDUNG. Sonst waere nach drei /* AN DER RUNDE UND NICHT AN DER MELDUNG. Sonst waere nach drei
Runden nicht mehr zu sehen, welches Bild zu welchem Versuch Runden nicht mehr zu sehen, welches Bild zu welchem Versuch
gehoerte. */ gehoerte.
ok(/\/runde\/1\/bild$/.test(r1?.urteil_bild || ""), SEIT DEM 02.10. STEHT DAS NICHT MEHR IM WEG (die Adresse nennt
" und zwar an DIESER Runde, nicht oben an der Meldung"); nur noch die Bildnummer), also wird es dort geprueft, wo es
jetzt steht: Die Meldung selbst hat KEIN Bild bekommen. Das
ist die staerkere Pruefung -- sie misst die Zuordnung in den
Daten statt eine Zeichenkette. */
ok((stand?.bilder || []).length === 0,
` und NICHT oben an der Meldung (${(stand?.bilder || []).length} dort)`);
ok(/noch nicht|nicht/.test(r1?.urteil || ""), ` das Urteil steht dabei (${r1?.urteil})`); ok(/noch nicht|nicht/.test(r1?.urteil || ""), ` das Urteil steht dabei (${r1?.urteil})`);
ok(/springt sie nach oben/.test(r1?.urteil_text || ""), ok(/springt sie nach oben/.test(r1?.urteil_text || ""),
` und der Text kam durch den Kopf heil an („${r1?.urteil_text}")`); ` und der Text kam durch den Kopf heil an („${r1?.urteil_text}")`);
/* ---- Das Bild kommt auch heraus ------------------------------ */ /* ---- Das Bild kommt auch heraus ------------------------------ */
const holen = await hol("modi", r1.urteil_bild); const holen = await hol("modi", r1weg);
const bytes = Buffer.from(await holen.arrayBuffer()); const bytes = Buffer.from(await holen.arrayBuffer());
ok(holen.status === 200 && bytes.length === PNG.length, ok(holen.status === 200 && bytes.length === PNG.length,
`der Melder bekommt es wieder (HTTP ${holen.status}, ${bytes.length} von ${PNG.length} Bytes)`); `der Melder bekommt es wieder (HTTP ${holen.status}, ${bytes.length} von ${PNG.length} Bytes)`);
@@ -620,21 +663,25 @@ melde("\n=== Sich kuemmern ===");
ok(holen.headers.get("accept-ranges") === "bytes", ok(holen.headers.get("accept-ranges") === "bytes",
"und ueber den gemeinsamen Ausliefer-Weg (Accept-Ranges)"); "und ueber den gemeinsamen Ausliefer-Weg (Accept-Ranges)");
const auchLeitung = await hol("hand", r1.urteil_bild); const auchLeitung = await hol("hand", r1weg);
ok(auchLeitung.status === 200, ok(auchLeitung.status === 200,
`die Leitung sieht es auch (${auchLeitung.status}) — sie soll ja darauf schauen`); `die Leitung sieht es auch (${auchLeitung.status}) — sie soll ja darauf schauen`);
/* ---- GEGENPROBEN ---------------------------------------------- */ /* ---- GEGENPROBEN ---------------------------------------------- */
const fremder = await hol("gast", r1.urteil_bild); const fremder = await hol("gast", r1weg);
ok(fremder.status === 404, ok(fremder.status === 404,
`ein Fremder bekommt 404 (${fremder.status}) — nicht 403, sonst waere die Nummer verraten`); `ein Fremder bekommt 404 (${fremder.status}) — nicht 403, sonst waere die Nummer verraten`);
const ohneAnmeldung = await fetch(BASIS + r1.urteil_bild); const ohneAnmeldung = await fetch(BASIS + r1weg);
ok(ohneAnmeldung.status !== 200, ok(ohneAnmeldung.status !== 200,
`ohne Anmeldung gar nichts (${ohneAnmeldung.status})`); `ohne Anmeldung gar nichts (${ohneAnmeldung.status})`);
const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/runde/9/bild`); /* EINE BILDNUMMER, DIE ES NICHT GIBT. Vorher stand hier
ok(gibtsNicht.status === 404, `eine Runde ohne Bild gibt 404 (${gibtsNicht.status})`); „eine Runde ohne Bild" -- die Route dazu gibt es seit dem
02.10. nicht mehr, weil Bilder jetzt ihre eigene Nummer haben.
Gefragt wird dieselbe Sache an der neuen Tuer. */
const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/bild/999999`);
ok(gibtsNicht.status === 404, `eine erfundene Bildnummer gibt 404 (${gibtsNicht.status})`);
/* KEIN BILD IST WEITERHIN ERLAUBT -- ein Bildschirmfoto ist Hilfe, /* KEIN BILD IST WEITERHIN ERLAUBT -- ein Bildschirmfoto ist Hilfe,
keine Huerde. Ohne diese Zeile koennte die Pflicht hereinrutschen, keine Huerde. Ohne diese Zeile koennte die Pflicht hereinrutschen,
@@ -665,6 +712,322 @@ melde("\n=== Sich kuemmern ===");
} }
} }
/* =====================================================================
5b. MEHRERE BILDER JE MELDUNG (02.10.2026)
=====================================================================
VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufuegen bei einer
Meldung. 2-3 waeren besser." Und in Runde 2 der Grund, warum der
erste Anlauf nicht reichte: „wenn man es nacheinander versucht
hinzuzufuegen wird das Bild immer nur ersetzt."
DREI VERSCHIEDENE FORMATE MIT DREI VERSCHIEDENEN LAENGEN (PNG 70,
GIF 42, WebP 34). Waeren alle drei gleich lang, wuerde ein
Fehlschnitt im Rumpf gar nicht auffallen -- siehe die Begruendung
bei den Konstanten oben.
===================================================================== */
melde("\n=== Mehrere Bilder ===");
{
const drei = [PNG, GIF, WEBP];
const dreiAn = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: {
"x-text": encodeURIComponent("Vorher, der Klick und nachher - drei Bilder."),
"Content-Type": "application/octet-stream",
"x-bilder": drei.map((b) => b.length).join(","),
},
body: Buffer.concat(drei),
});
ok(dreiAn.status === 201, `drei Bilder auf einmal (${dreiAn.status})`);
const dreiM = dreiAn.status === 201 ? (await dreiAn.json()).meldung : null;
ok((dreiM?.bilder || []).length === 3,
`und alle drei haengen dran (${(dreiM?.bilder || []).length})`);
/* JEDES BILD EINZELN NACHGEMESSEN -- Reihenfolge, Laenge, Typ.
Das ist der eigentliche Beweis, dass der Rumpf richtig zerlegt
wurde: Eine um ein Byte verschobene Grenze gaebe hier sofort
einen anderen Typ oder eine andere Laenge. */
const erwartet = [
{ bytes: PNG, typ: "image/png" },
{ bytes: GIF, typ: "image/gif" },
{ bytes: WEBP, typ: "image/webp" },
];
let stimmen = 0;
for (const [i, e] of erwartet.entries()) {
const weg = dreiM?.bilder?.[i]?.weg;
if (!weg) continue;
const a = await hol("modi", weg);
const roh = Buffer.from(await a.arrayBuffer());
if (a.status === 200 && roh.equals(e.bytes)
&& a.headers.get("content-type") === e.typ) stimmen++;
}
/* DIE ZAHL STEHT IN DER BEDINGUNG, nicht nur im Text -- sonst waere
die Pruefung auch mit null geholten Bildern gruen. */
ok(stimmen === 3,
`jedes kommt in SEINER Reihenfolge und mit SEINEM Typ zurueck (${stimmen} von 3)`);
/* ---- Die Grenze gilt ------------------------------------------- */
const vier = [PNG, GIF, WEBP, PNG];
const zuViele = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: {
"x-text": encodeURIComponent("Hier sind es vier Bilder, das ist eines zu viel."),
"Content-Type": "application/octet-stream",
"x-bilder": vier.map((b) => b.length).join(","),
},
body: Buffer.concat(vier),
});
ok(zuViele.status === 400, `vier Bilder sind eines zu viel (${zuViele.status})`);
/* ---- Eine falsche Laenge wird nicht einfach hingenommen -------- */
const schief = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: {
"x-text": encodeURIComponent("Die Laengen im Kopf passen nicht zum Rumpf."),
"Content-Type": "application/octet-stream",
"x-bilder": `${PNG.length},${GIF.length - 5}`,
},
body: Buffer.concat([PNG, GIF]),
});
ok(schief.status === 400,
`eine Summe, die nicht aufgeht, wird abgelehnt (${schief.status})`);
/* ---- UND EIN FAULES UNTER DREI VERDIRBT ALLE DREI -------------- */
const faul = [PNG, Buffer.from("<html><script>alert(1)</script></html>"), GIF];
const gemischt = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: {
"x-text": encodeURIComponent("Zwei Bilder und eine getarnte HTML-Datei dazwischen."),
"Content-Type": "application/octet-stream",
"x-bilder": faul.map((b) => b.length).join(","),
},
body: Buffer.concat(faul),
});
ok(gemischt.status === 415,
`ein getarntes HTML zwischen zwei Bildern kippt die ganze Meldung (${gemischt.status})`);
/* GEGENPROBE ZUM AUFRAEUMEN: Die abgelehnte Meldung darf es nicht
geben -- weder als Zeile noch halb, mit dem einen Bild, das vor
dem faulen schon geschrieben war. */
const listeDanach = await (await hol("modi", "/workspace/api/support")).json();
const geister = (listeDanach.meldungen || [])
.filter((m) => /getarnte HTML-Datei/.test(m.text || "")).length;
ok(geister === 0, `und hinterlaesst keine halbe Meldung (${geister})`);
/* ---- OHNE DEN KOPF IST ES EIN BILD, wie vor dem Umbau ---------- */
const altWeg = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: {
"x-text": encodeURIComponent("Eine Seite aus dem Zwischenspeicher schickt ohne x-bilder."),
"Content-Type": "image/png",
},
body: PNG,
});
const altM = altWeg.status === 201 ? (await altWeg.json()).meldung : null;
ok(altWeg.status === 201 && (altM?.bilder || []).length === 1,
`ohne x-bilder gilt der ganze Rumpf als EIN Bild (${altWeg.status}, `
+ `${(altM?.bilder || []).length})`);
/* ---- NACHLEGEN IN DERSELBEN RUNDE ERSETZT, statt zu haeufen ---- */
const fuerRunde = await hol("modi", "/workspace/api/support", {
method: "POST",
headers: { "Content-Type": "text/plain",
"x-text": encodeURIComponent("Fuer die Runde mit mehreren Bildern.") },
});
const rId = (await fuerRunde.json())?.meldung?.id;
await hol("hand", `/workspace/api/support/${rId}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stand: "erledigt", antwort: "Schau mal." }),
});
const zweiAn = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
"x-text": encodeURIComponent("Geht noch nicht, hier zwei Bilder."),
"x-bilder": `${PNG.length},${GIF.length}` },
body: Buffer.concat([PNG, GIF]),
});
ok(zweiAn.status === 200, `zwei Bilder zur Antwort (${zweiAn.status})`);
const standR = await (await hol("modi", "/workspace/api/support")).json();
const mR = (standR.meldungen || []).find((m) => m.id === rId);
const runde1 = (mR?.verlauf || [])[0];
ok((runde1?.bilder || []).length === 2,
`an der Runde haengen beide (${(runde1?.bilder || []).length})`);
/* ---- ZWEIMAL IN DERSELBEN RUNDE GEHT GAR NICHT ----------------
Hier stand erst eine Pruefung „nachgelegt ersetzt die Bilder" --
und sie wurde rot. Nachgemessen war nicht der Server kaputt,
sondern meine Annahme: Eine zweite Antwort in DERSELBEN Runde
kann es nicht geben. Die erste verlaesst den Stand „wartet", und
ohne den nimmt die Route nichts mehr an. Die Sperre im Server
ist daraufhin weggefallen (sie waere Code gewesen, der nie
laeuft); hier steht jetzt der Beweis, dass sie nicht fehlt. */
const zweitesMal = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
"x-text": encodeURIComponent("Und noch einmal, in derselben Runde."),
"x-bilder": `${WEBP.length}` },
body: WEBP,
});
ok(zweitesMal.status === 409,
`in derselben Runde ein zweites Mal antworten geht nicht (${zweitesMal.status})`);
const standR2 = await (await hol("modi", "/workspace/api/support")).json();
const runde1b = ((standR2.meldungen || []).find((m) => m.id === rId)?.verlauf || [])[0];
ok((runde1b?.bilder || []).length === 2,
` und die zwei Bilder von vorhin stehen unveraendert da (${(runde1b?.bilder || []).length})`);
/* ---- JEDE RUNDE HAELT IHRE EIGENEN BILDER ---------------------
DAS IST DIE EIGENTLICHE GEFAHR BEI EINER TABELLE FUER ALLES:
dass Runde 2 die Bilder von Runde 1 mitzeigt oder umgekehrt.
Nach drei Runden waere dann nicht mehr zu sehen, welches Bild zu
welchem Versuch gehoerte -- und genau diese Frage loest einen
wiederkehrenden Fehler. */
await hol("hand", `/workspace/api/support/${rId}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stand: "erledigt", antwort: "Zweiter Versuch." }),
});
const r2an = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream", "x-geht": "nein",
"x-text": encodeURIComponent("Runde zwei, ein anderes Bild."),
"x-bilder": `${WEBP.length}` },
body: WEBP,
});
const standR3 = await (await hol("modi", "/workspace/api/support")).json();
const verlauf3 = (standR3.meldungen || []).find((m) => m.id === rId)?.verlauf || [];
ok(r2an.status === 200 && (verlauf3[1]?.bilder || []).length === 1,
`Runde 2 hat ihr eigenes Bild (${(verlauf3[1]?.bilder || []).length})`);
ok((verlauf3[0]?.bilder || []).length === 2,
` und Runde 1 behaelt ihre zwei (${(verlauf3[0]?.bilder || []).length})`);
/* GEGENPROBE: Es sind wirklich ANDERE Bilder und nicht dieselben
Zeilen zweimal gezeigt. */
const nummern = new Set([
...(verlauf3[0]?.bilder || []).map((b) => b.id),
...(verlauf3[1]?.bilder || []).map((b) => b.id),
]);
ok(nummern.size === 3, `und alle drei sind verschiedene Bilder (${nummern.size})`);
/* ---- UND EINE ANTWORT OHNE BILD IST WEITERHIN ERLAUBT --------- */
await hol("hand", `/workspace/api/support/${rId}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stand: "erledigt", antwort: "Dritter Versuch." }),
});
const ohneAnhang = await hol("modi", `/workspace/api/support/${rId}/rueckmeldung`, {
method: "POST",
headers: { "x-geht": "nein", "x-text": encodeURIComponent("Nur der Text, kein Bild.") },
});
const standR4 = await (await hol("modi", "/workspace/api/support")).json();
const verlauf4 = (standR4.meldungen || []).find((m) => m.id === rId)?.verlauf || [];
ok(ohneAnhang.status === 200 && (verlauf4[2]?.bilder || []).length === 0,
`Runde 3 ohne Bild ist in Ordnung (${ohneAnhang.status}, `
+ `${(verlauf4[2]?.bilder || []).length} Bilder)`);
ok((verlauf4[0]?.bilder || []).length === 2 && (verlauf4[1]?.bilder || []).length === 1,
" und loescht an den Runden davor nichts");
/* ---- UND DIE OBERFLAECHE ERFAEHRT DIE GRENZE ------------------- */
const liste = await (await hol("modi", "/workspace/api/support")).json();
ok(liste.bilder_max === 3,
`der Server nennt die Grenze, damit sie im Browser nicht zweimal steht (${liste.bilder_max})`);
}
/* =====================================================================
5c. DER UMZUG DER ALTEN BILDER (02.10.2026)
=====================================================================
DIE WICHTIGSTE PRUEFUNG DIESES UMBAUS. Live liegen acht Meldungen
mit Bild in den alten Spalten `support_meldungen.bild_datei` und
dazu die Bilder an den Antwortrunden. Ab heute liest der Server
NUR noch `support_bilder`. Ohne den Umzug waeren diese Bilder
stillschweigend verschwunden -- kein Fehler, keine rote Zeile, nur
acht leere Karten. Genau die Sorte Schaden, die am 11.09. drei
Spalten gekostet hat.
SIE LAEUFT AUF EINER EIGENEN WEGWERF-DATENBANK und ruft
`supportTabellen()` direkt auf. Ueber den laufenden Server waere
es nicht zu messen: Dort ist die Datenbank frisch, es gibt gar
keine alten Zeilen, und die Pruefung waere gruen, ohne etwas
gesehen zu haben.
===================================================================== */
melde("\n=== Der Umzug der alten Bilder ===");
{
const { DatabaseSync } = await import("node:sqlite");
const { supportTabellen } = await import("./support-tabellen.js");
const altDatei = join(ordner, "alt-bestand.db");
const a = new DatabaseSync(altDatei);
/* DER STAND VON GESTERN, von Hand nachgebaut: die Tabellen OHNE
`support_bilder`, mit den Bildspalten gefuellt. */
a.exec(`
CREATE TABLE support_meldungen (
id INTEGER PRIMARY KEY AUTOINCREMENT, person_id INTEGER,
von_name TEXT, von_rolle TEXT, text TEXT NOT NULL, seite TEXT, geraet TEXT,
bild_datei TEXT, bild_typ TEXT, bild_breite INTEGER, bild_hoehe INTEGER,
stand TEXT NOT NULL DEFAULT 'neu', antwort TEXT, runde INTEGER NOT NULL DEFAULT 1,
erstellt TEXT NOT NULL, geaendert TEXT NOT NULL,
erledigt_am TEXT, erledigt_von INTEGER);
CREATE TABLE support_runden (
meldung_id INTEGER NOT NULL, nr INTEGER NOT NULL, antwort TEXT,
von_id INTEGER, von_name TEXT, am TEXT NOT NULL,
urteil TEXT, urteil_text TEXT, urteil_am TEXT);
INSERT INTO support_meldungen (text, bild_datei, bild_typ, bild_breite, bild_hoehe,
erstellt, geaendert)
VALUES ('mit Bild', 'alt-eins.png', 'image/png', 100, 240, '2026-09-01T10:00:00Z',
'2026-09-01T10:00:00Z');
INSERT INTO support_meldungen (text, erstellt, geaendert)
VALUES ('ohne Bild', '2026-09-02T10:00:00Z', '2026-09-02T10:00:00Z');
INSERT INTO support_runden (meldung_id, nr, antwort, am, urteil, urteil_am)
VALUES (1, 1, 'Probier es nochmal.', '2026-09-03T10:00:00Z',
'nicht', '2026-09-03T11:00:00Z');
`);
/* Das Bild der RUNDE steht absichtlich noch nicht drin: Die vier
Spalten dafuer gibt es in einer Datenbank von gestern gar nicht.
Genau das muss `supportTabellen` erst nachruesten -- und DANACH
umziehen. Stuende der Umzug vor der Nachruestung, scheiterte er
hier und nur hier, naemlich auf einer alten Datenbank. */
supportTabellen(a);
a.exec(`UPDATE support_runden SET urteil_bild_datei = 'alt-runde.gif',
urteil_bild_typ = 'image/gif', urteil_bild_breite = 50, urteil_bild_hoehe = 60
WHERE meldung_id = 1 AND nr = 1`);
/* ZWEITER DURCHLAUF -- so wie beim naechsten Neustart des Dienstes.
Er muss das Rundenbild nachholen UND das Meldungsbild NICHT
verdoppeln. */
supportTabellen(a);
const alle = a.prepare("SELECT * FROM support_bilder ORDER BY id").all();
ok(alle.length === 2, `beide alten Bilder sind umgezogen (${alle.length})`);
const zurMeldung = alle.filter((b) => b.runde_nr === null);
ok(zurMeldung.length === 1 && zurMeldung[0].datei === "alt-eins.png"
&& zurMeldung[0].typ === "image/png" && zurMeldung[0].breite === 100
&& zurMeldung[0].hoehe === 240,
` das Bild der Meldung mit Datei, Typ und Groesse (${zurMeldung[0]?.datei}, `
+ `${zurMeldung[0]?.breite}x${zurMeldung[0]?.hoehe})`);
const zurRunde = alle.filter((b) => b.runde_nr === 1);
ok(zurRunde.length === 1 && zurRunde[0].datei === "alt-runde.gif",
` und das Bild der Antwort an SEINER Runde (${zurRunde[0]?.datei})`);
ok(zurMeldung[0]?.erstellt === "2026-09-01T10:00:00Z",
` mit dem Datum von damals, nicht dem von heute (${zurMeldung[0]?.erstellt})`);
/* DRITTER DURCHLAUF: Er darf nichts mehr tun. Ein Umzug, der bei
jedem Neustart dieselben Zeilen noch einmal anlegt, fuellt die
Karte nach einer Woche mit sieben gleichen Bildern. */
supportTabellen(a);
const nochmal = a.prepare("SELECT COUNT(*) AS n FROM support_bilder").get().n;
ok(nochmal === 2, `ein weiterer Start verdoppelt nichts (${nochmal})`);
/* GEGENPROBE: Die Meldung OHNE Bild bekommt auch keines. Ohne
diese Zeile koennte der Umzug alles verdoppeln und waere noch
immer gruen. */
const beiOhne = a.prepare("SELECT COUNT(*) AS n FROM support_bilder WHERE meldung_id = 2")
.get().n;
ok(beiOhne === 0, `eine Meldung ohne Bild bekommt keines (${beiOhne})`);
a.close();
}
/* ===================================================================== /* =====================================================================
6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden? 6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden?
===================================================================== */ ===================================================================== */
@@ -680,7 +1043,7 @@ melde("\n=== Gegenproben (jede MUSS anschlagen) ===");
`ohne Anmeldung keine Liste (${ohne.status})`); `ohne Anmeldung keine Liste (${ohne.status})`);
/* Und ohne Anmeldung auch nicht an ein Bild. */ /* Und ohne Anmeldung auch nicht an ein Bild. */
const ohneBild = await fetch(BASIS + `/workspace/api/support/${meldungMitBild}/bild`); const ohneBild = await fetch(BASIS + bildWeg);
ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`); ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`);
} }
+24 -2
View File
@@ -892,8 +892,30 @@ melde("=== 6. Schreiben ===");
text: "Ansage", datum: heuteLokal() }); text: "Ansage", datum: heuteLokal() });
ok(brettDesTeams.code === 403, `aufs Anschlagbrett schreibt nur das Team (${brettDesTeams.code})`); ok(brettDesTeams.code === 403, `aufs Anschlagbrett schreibt nur das Team (${brettDesTeams.code})`);
const beiDogi = await brett("treff", HAUS, kDogi); /* ==== AUF DER CREW-ADRESSE, NICHT AUF DER AGENTURADRESSE ========
ok(beiDogi.titel.includes("GAST-SCHREIBT-8899"), "DogFather sieht den Beitrag"); (nachgezogen 03.10.2026)
Hier stand `brett("treff", HAUS, kDogi)` -- DogFather liest das
Rudel-Brett ueber die AGENTURADRESSE. Das war richtig, solange
"getrennt wird das AUSSEHEN, nicht der Bestand" galt. Filipe hat
das am 24.09.2026 umgedreht: "wenn ich bei der einen was mache
soll nichts bei der anderen passieren."
Seit Riegel 0 in sichtbarEintrag() gibt es auf der Agenturadresse
keine Eintraege von Team Dogi mehr -- auch nicht fuer DogFather.
`treff` ist ein Brett des Rudels; dass dort auf workspace. nichts
steht, ist das Ziel und kein Fehler.
DIE ZUSAGE BLEIBT, sie wird nur am richtigen Ort geprueft -- und
bekommt eine GEGENPROBE dazu. Damit sagt diese Stelle jetzt mehr
als vorher: nicht nur "er sieht es", sondern "er sieht es DORT
und nur dort". */
const beiDogi = await brett("treff", CREW, kDogi);
ok(beiDogi.titel.includes("GAST-SCHREIBT-8899"),
"DogFather sieht den Beitrag auf der Crew-Adresse");
const beiDogiAgentur = await brett("treff", HAUS, kDogi);
ok(!beiDogiAgentur.titel.includes("GAST-SCHREIBT-8899"),
`und auf der Agenturadresse nicht (${beiDogiAgentur.titel.join(" | ") || "leer"})`);
const beiModi = await brett("treff", CREW, kModi); const beiModi = await brett("treff", CREW, kModi);
ok(beiModi.titel.includes("GAST-SCHREIBT-8899"), "der Modi auch"); ok(beiModi.titel.includes("GAST-SCHREIBT-8899"), "der Modi auch");
const beiCrea = await brett("treff", HAUS, kCrea); const beiCrea = await brett("treff", HAUS, kCrea);
+125 -3
View File
@@ -79,7 +79,7 @@ melde("\n=== 1. Die Regel ===");
const { DatabaseSync } = await import("node:sqlite"); const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const jetzt = new Date().toISOString(); const jetzt = new Date().toISOString();
let idDogi, idHand, idModi, idModi2; let idDogi, idHand, idModi, idModi2, idLinke;
{ {
const d = new DatabaseSync(process.env.WORKSPACE_DB); const d = new DatabaseSync(process.env.WORKSPACE_DB);
const k = randomBytes(32).toString("hex"); const k = randomBytes(32).toString("hex");
@@ -97,6 +97,10 @@ let idDogi, idHand, idModi, idModi2;
idHand = anlegen("VanVan", "hand", "CODE-H"); idHand = anlegen("VanVan", "hand", "CODE-H");
idModi = anlegen("Diene", "modi", "CODE-M"); idModi = anlegen("Diene", "modi", "CODE-M");
idModi2 = anlegen("Ghost", "modi", "CODE-M2"); idModi2 = anlegen("Ghost", "modi", "CODE-M2");
/* DIE LINKE HAND IST DIE GEGENPROBE (03.10.2026). `istHand` fasst
beide Haende zusammen -- haette das neue Loeschrecht daran
gehangen, bekaeme sie es lautlos mit. */
idLinke = anlegen("Funny", "linke", "CODE-L");
d.close(); d.close();
} }
@@ -132,6 +136,7 @@ melde("\n=== 2. Durch die Schnittstelle ===");
const kDogi = await anmelden("admin", "CODE-A"); const kDogi = await anmelden("admin", "CODE-A");
const kHand = await anmelden("hand", "CODE-H"); const kHand = await anmelden("hand", "CODE-H");
const kModi = await anmelden("modi", "CODE-M"); const kModi = await anmelden("modi", "CODE-M");
const kLinke = await anmelden("linke", "CODE-L");
ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet"); ok(!!kDogi && !!kHand && !!kModi, "DogFather, rechte Hand und ein Modi sind angemeldet");
/* Sagt der Server es auch der Seite? */ /* Sagt der Server es auch der Seite? */
@@ -355,8 +360,125 @@ melde("\n=== 5. Die rechte Hand an ihren eigenen Aufgaben ===");
{ titel: "Nur fuer DogFather", verantwortlich_id: idDogi, prioritaet: "mittel" }); { titel: "Nur fuer DogFather", verantwortlich_id: idDogi, prioritaet: "mittel" });
const id3 = json(fremd).id; const id3 = json(fremd).id;
const handFremd = await ruf(`/workspace/api/aufgaben/${id3}`, kHand, "DELETE"); const handFremd = await ruf(`/workspace/api/aufgaben/${id3}`, kHand, "DELETE");
ok(fremd.code === 201 && handFremd.code === 403, ok(fremd.code === 201 && handFremd.code === 200,
`Gegenprobe: eine fremde Aufgabe loescht sie nicht (${handFremd.code})`); `und auch eine fremde Aufgabe von DogFather (${handFremd.code})`);
}
/* =====================================================================
6. IMMER UND IN JEDER ETAPPE (03.10.2026)
Filipe: "ich will noch dass die rechte hand und dogfather bei den
aufgaben immer die option haben aufgaben zu loeschen. die soll es
immer geben in jeder etape."
HIER STAND BIS HEUTE DAS GEGENTEIL. Die letzte Zeile des Abschnitts
darueber lautete: "Gegenprobe: eine fremde Aufgabe loescht sie
nicht (403)". Am 25.09. war das richtig -- damals hiess der Auftrag
"ihre eigenen". Jetzt heisst er "immer", und die Zeile ist
mitgewandert statt stehenzubleiben. Eine Pruefung, die eine
zurueckgenommene Regel weiter verteidigt, haelt die Aenderung auf
und sieht dabei aus wie Sorgfalt.
WARUM ALLE VIER ETAPPEN EINZELN: Der Loeschweg fragt heute keinen
Status ab -- diese Pruefung zielt also nicht auf einen bekannten
Fehler, sondern auf einen kuenftigen. "In jeder etape" ist eine
Zusage; ohne die vier Durchlaeufe koennte sie jemand mit einer gut
gemeinten Statusbedingung aufheben, ohne dass etwas rot wird.
GEGENGEWICHT: Die LINKE Hand bekommt das Recht NICHT. `istHand`
fasst beide Haende zusammen, und haette ich das genommen, waere es
lautlos an sie mitgegangen -- Filipe hat zwei Rollen genannt, nicht
drei. Diese Gegenprobe ist der einzige Ort, an dem der Unterschied
auffallen wuerde.
===================================================================== */
melde("\n=== 6. Loeschen: immer, in jeder Etappe ===");
{
/* FUENF, NICHT VIER -- und die fuenfte hat mir die Live-Datenbank
gezeigt (03.10.2026).
Mein erster Satz war ["offen","arbeit","review","erledigt"], die
Liste aus `STATUS` in workspace-aufgaben.js. Beim Nachsehen auf
dem laufenden Server standen dort aber auch zwei Aufgaben auf
"abgebrochen". Der Grund: "abgebrochen" steht absichtlich NICHT
in STATUS, damit der normale Weg es nicht setzen kann -- es
kommt ueber eine eigene Route. Wer die Etappen aus STATUS
ableitet, uebersieht also ausgerechnet den Endzustand, in dem
Aufgaben am laengsten liegen bleiben.
Genau dieselbe Luecke hat in dieser Datei schon einmal zwei
Bedingungen erwischt, die nur gegen 'erledigt' prueften. */
const ETAPPEN = ["offen", "arbeit", "review", "erledigt", "abgebrochen"];
let konnteHand = 0, konnteDogi = 0, konnteLinkeNicht = 0, wirklichWeg = 0;
for (const stand of ETAPPEN) {
/* Je Etappe drei Aufgaben, alle von DogFather an einen Modi --
die rechte Hand hat mit keiner davon etwas zu tun. */
const bauen = async (titel) => {
const r = await ruf("/workspace/api/aufgaben", kDogi, "POST",
{ titel, verantwortlich_id: idModi, prioritaet: "mittel" });
const id = json(r).id;
if (stand === "abgebrochen") {
/* Eigener Weg mit Pflichtbegruendung -- per PATCH laesst sich
dieser Stand gar nicht setzen. */
await ruf(`/workspace/api/aufgaben/${id}/abbrechen`, kDogi, "POST",
{ grund: "Pruefung: Loeschen in jeder Etappe" });
} else if (stand !== "offen") {
await ruf(`/workspace/api/aufgaben/${id}`, kDogi, "PATCH", { status: stand });
}
return id;
};
const fuerHand = await bauen(`Fremd fuer die rechte Hand (${stand})`);
const fuerDogi = await bauen(`Fremd fuer DogFather (${stand})`);
const fuerLinke = await bauen(`Fremd fuer die linke Hand (${stand})`);
/* Steht die Etappe wirklich? Ohne das waeren alle vier Durchlaeufe
in Wahrheit viermal "offen" -- und die Pruefung gruen, ohne je
etwas anderes gesehen zu haben. */
/* AUS DER DATENBANK, NICHT AUS DER LISTE. Abgebrochene Aufgaben
werden an mehreren Stellen bewusst ausgeblendet -- die Liste
koennte sie also gar nicht zeigen, und "Etappe steht" waere
dann nicht messbar. Die Tabelle luegt nicht. */
const dS = new DatabaseSync(process.env.WORKSPACE_DB);
const echt = dS.prepare("SELECT status FROM aufgaben WHERE id = ?").get(fuerHand);
dS.close();
ok(echt?.status === stand, `Etappe "${stand}" steht wirklich (${echt?.status})`);
/* Und die Oberflaeche bekommt es auch gesagt -- sonst gaebe es den
Knopf gar nicht, egal was der Server erlaubt. */
const beiHand = (json(await ruf("/workspace/api/aufgaben", kHand)).aufgaben || [])
.find((a) => a.id === fuerHand);
/* Steht sie in IHRER Liste, muss auch der Knopf angeboten werden.
Blendet das Haus diese Etappe fuer sie aus, gibt es dort keinen
Knopf zu zeigen -- dann ist der Server allein zustaendig, und
den misst der DELETE zwei Zeilen weiter. */
ok(beiHand === undefined || beiHand.darf_loeschen === true,
beiHand === undefined
? ` (in dieser Etappe steht sie nicht in ihrer Liste -- nur der Server zaehlt)`
: ` der rechten Hand wird Loeschen angeboten (${beiHand.darf_loeschen})`);
if ((await ruf(`/workspace/api/aufgaben/${fuerHand}`, kHand, "DELETE")).code === 200) konnteHand++;
if ((await ruf(`/workspace/api/aufgaben/${fuerDogi}`, kDogi, "DELETE")).code === 200) konnteDogi++;
if ((await ruf(`/workspace/api/aufgaben/${fuerLinke}`, kLinke, "DELETE")).code === 403) konnteLinkeNicht++;
/* Weg heisst weg -- in der Datenbank, nicht nur im Rueckgabewert. */
const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
const da = d3.prepare("SELECT COUNT(*) AS n FROM aufgaben WHERE id IN (?,?)")
.get(fuerHand, fuerDogi).n;
const linkeNochDa = d3.prepare("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ?")
.get(fuerLinke).n;
d3.close();
if (da === 0 && linkeNochDa === 1) wirklichWeg++;
}
const n = ETAPPEN.length;
ok(n === 5 && konnteHand === n,
`die rechte Hand loescht in allen ${n} Etappen (${konnteHand}/${n})`);
ok(konnteDogi === n, `DogFather ebenso (${konnteDogi}/${n})`);
ok(konnteLinkeNicht === n,
`Gegenprobe: die LINKE Hand darf es in keiner (${konnteLinkeNicht}/${n} mal abgelehnt)`);
ok(wirklichWeg === n,
`und geloescht ist geloescht -- die abgelehnte steht noch da (${wirklichWeg}/${n})`);
} }
console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`); console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
+309
View File
@@ -0,0 +1,309 @@
/* =====================================================================
WERDEN DIE MONATSZIEL-ERINNERUNGEN WIRKLICH ZUGESTELLT? (02.10.2026)
=====================================================================
`pruef-manager-ziele` prueft, was `zielRufe()` ZURUECKGIBT -- eine
Liste. Eine Liste ist aber keine Benachrichtigung. Zwischen ihr und
dem Handy liegen noch: der Fuenf-Minuten-Takt, die Artenliste, der
Schalter in der Glocke, die Ruhezeit, die Verschluesselung und das
Merkmal gegen Doppelsendungen.
Dieser Weg ist genauso nie gelaufen wie der Monatswechsel -- und
er laeuft zum ersten Mal am 1. November um 00:05 Uhr, von selbst,
fuer alle gleichzeitig.
---------------------------------------------------------------------
DIE WICHTIGSTE FRAGE STEHT GLEICH AM ANFANG
Am 1. um 00:05 ist RUHEZEIT (Vorgabe 22 bis 7). Die Meldung darf
dann nicht herausgehen -- aber sie darf auch nicht VERFALLEN. Das
haengt an einer einzigen Zeile in `benachrichtige`: Das Merkmal
wird erst geschrieben, NACHDEM wirklich zugestellt wurde. Waere es
umgekehrt, bekaeme am 1. November niemand eine Meldung, und zwar
fuer immer -- der Takt haette sie als „schon geschickt" abgehakt,
waehrend alle schliefen.
---------------------------------------------------------------------
GEMESSEN WERDEN ZUSTAENDE, NICHT ZEITPUNKTE
Der Server startet seinen eigenen Takt (alle fuenf Minuten, der
erste Lauf nach zwanzig Sekunden). Er kann also jederzeit
mitten in diese Pruefung hineinlaufen. Deshalb steht hier nirgends
„nach meinem Aufruf kamen genau drei dazu", sondern „in
push_verschickt steht jetzt genau eine Zeile je Person" -- das
stimmt auch dann, wenn der Takt dieselbe Arbeit vorher gemacht hat.
Dieselbe Falle wie am 06.09.2026 in RunOne, als zwei Prueflaeufe
gleichzeitig liefen und sechzehn Fehler meldeten, von denen keiner
aus dem Code kam.
===================================================================== */
/* ---------- Die Uhr ---------------------------------------------- */
const ECHTE_ZEIT = Date;
let versatz = 0;
globalThis.Date = class extends ECHTE_ZEIT {
constructor(...a) {
if (a.length === 0) super(ECHTE_ZEIT.now() + versatz);
else super(...a);
}
static now() { return ECHTE_ZEIT.now() + versatz; }
};
function uhrAuf(iso) {
versatz = 0;
versatz = new ECHTE_ZEIT(iso).getTime() - ECHTE_ZEIT.now();
}
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-ziel-zustellung");
const DIENST = await eigenerPort(import.meta, "pruef-ziel-zustellung (Push-Dienst)", 1);
const ordner = mkdtempSync(join(tmpdir(), "ws-zz-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
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 BLEIBT AUF DER VORGABE (22 bis 7). Sie wird hier nicht
per Umgebungsvariable verstellt, sondern ueber die Uhr erreicht --
denn genau die Uhrzeit ist der Fall, der am 1. November eintritt.
Eine gefaelschte Ruhezeit um neun Uhr morgens wuerde eine andere
Frage beantworten als die, die sich stellt. */
/* 1. NOVEMBER, 00:05 -- fuenf Minuten nach dem Monatswechsel, mitten
in der Ruhezeit. Genau so wird es wirklich sein. */
uhrAuf("2026-11-01T00:05:00+01:00");
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(240_000, "pruef-ziel-zustellung");
await new Promise((r) => setTimeout(r, 800));
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
function braucht(bedingung, was) {
if (bedingung) return;
console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`);
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
const { DatabaseSync } = await import("node:sqlite");
const { randomBytes, createECDH } = await import("node:crypto");
const push = await import("./workspace-push.js");
/* ---------- Ein Push-Dienst, der alles annimmt -------------------- */
let zustellungen = 0;
const dienst = createServer((q, a) => {
q.on("data", () => {});
q.on("end", () => { zustellungen++; a.writeHead(201); a.end(); });
});
await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r));
/* ---------- Leute mit Geraeten ------------------------------------ */
const ALT = "2025-10-01T08:00:00Z";
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const anlegen = (name, rolle) => {
d.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)
VALUES (?,?,?,?,32768,1,?)`)
.run(name, rolle, randomBytes(32).toString("hex"),
randomBytes(16).toString("hex"), ALT);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
const idOffen1 = anlegen("Offen Eins", "scout");
const idOffen2 = anlegen("Offen Zwei", "manager");
const idStumm = anlegen("Stumm", "scout");
const idOhneGeraet = anlegen("Ohne Geraet", "scout");
const idCreator = anlegen("Luna", "creator");
/* Echte Schluessel -- ein erfundener wuerde beim Verschluesseln
scheitern, und „nicht zugestellt" waere dann die Antwort auf eine
ganz andere Frage. */
function geraet(personId) {
const k = createECDH("prime256v1");
k.generateKeys();
d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
VALUES (?,?,?,?,?)`)
.run(personId, `http://127.0.0.1:${DIENST}/push/${personId}`,
k.getPublicKey().toString("base64url"), randomBytes(16).toString("base64url"),
new Date().toISOString());
}
for (const id of [idOffen1, idOffen2, idStumm, idCreator]) geraet(id);
/* „Stumm" hat die Art abgeschaltet -- das ist der Schalter aus der
Glocke, und er muss staerker sein als jede Erinnerung. */
d.prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,0)`)
.run(idStumm, "manager_ziele");
d.close();
const zaehle = (sql, ...w) => {
const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const n = db2.prepare(sql).get(...w)?.n ?? 0;
db2.close();
return n;
};
const mzMerkmale = () => zaehle(
"SELECT COUNT(*) n FROM push_verschickt WHERE merkmal LIKE 'mz:%'");
/* =====================================================================
0 · MISST DIESE PRUEFUNG UEBERHAUPT ETWAS?
=====================================================================
„Null zugestellt" ist gleich die erste erwartete Antwort. Ohne
diesen Beweis hiesse sie moeglicherweise „der Dienst ist gar nicht
angeschlossen" -- und die ganze Datei waere wertlos. Die Art
„test" traegt `ruhe: "nie"` und geht deshalb auch nachts durch.
===================================================================== */
melde("\n=== Zuerst: kann hier ueberhaupt etwas ankommen? ===");
const probe = await push.benachrichtige(idOffen1, "test",
{ titel: "Probe", text: "nur zum Messen", ziel: "/workspace/start.html" });
ok(probe.verschickt === 1,
`eine Probemeldung kommt an (${probe.verschickt}) — der Dienst ist angeschlossen`);
ok(zustellungen >= 1, `und der Zaehler bewegt sich (${zustellungen})`);
/* =====================================================================
1 · 1. NOVEMBER, 00:05 UHR -- RUHEZEIT
===================================================================== */
melde("\n=== 1. November, 00:05 Uhr — mitten in der Ruhezeit ===");
const vorher = zustellungen;
await push.laufen();
ok(zustellungen === vorher,
`nachts geht nichts raus (${zustellungen - vorher} zugestellt)`);
ok(mzMerkmale() === 0,
`UND das Merkmal wurde NICHT gesetzt (${mzMerkmale()})`
+ " — sonst waere die Meldung fuer immer verfallen, waehrend alle schliefen");
/* =====================================================================
2 · DERSELBE TAG, 09:00 UHR
===================================================================== */
melde("\n=== Derselbe 1. November, 09:00 Uhr ===");
uhrAuf("2026-11-01T09:00:00+01:00");
await push.laufen();
const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const zeilen = db3.prepare(
"SELECT person_id, merkmal FROM push_verschickt WHERE merkmal LIKE 'mz:%'").all();
db3.close();
const wer = new Set(zeilen.map((z) => z.person_id));
ok(zeilen.length === 2,
`genau zwei Meldungen sind heraus (${zeilen.length})`);
ok(wer.has(idOffen1) && wer.has(idOffen2),
"…an die beiden mit offenen Zielen und einem Geraet");
ok(!wer.has(idStumm),
"…NICHT an den, der die Art abgeschaltet hat — der Schalter schlaegt die Erinnerung");
ok(!wer.has(idCreator),
"…und nicht an einen Creator, der diese Pflichten gar nicht hat");
ok(!wer.has(idOhneGeraet),
"…und nicht an den ohne Geraet (es gibt niemanden, der sie annimmt)");
ok(zeilen.every((z) => z.merkmal === "mz:2026-11-01"),
`das Merkmal traegt den Tag (${zeilen[0]?.merkmal})`);
/* =====================================================================
3 · UND JETZT LAEUFT DER TAKT 287 WEITERE MALE
=====================================================================
Das ist der Fall, der im Betrieb wirklich eintritt: Der Lauf
findet alle fuenf Minuten statt. Ohne das Merkmal bekaeme jeder
288 Meldungen an einem Tag -- das waere kein Hinweis mehr, sondern
ein Grund, das Handy wegzulegen.
===================================================================== */
melde("\n=== Der Takt laeuft weiter ===");
const nachErstem = zustellungen;
await push.laufen();
await push.laufen();
await push.laufen();
ok(zustellungen === nachErstem,
`drei weitere Laeufe stellen nichts mehr zu (${zustellungen - nachErstem})`);
ok(mzMerkmale() === 2, `und es bleibt bei zwei Merkmalen (${mzMerkmale()})`);
/* =====================================================================
4 · DER 10. -- EIN NEUER TAG, EINE NEUE MELDUNG
===================================================================== */
melde("\n=== Der 10. November ===");
uhrAuf("2026-11-10T09:00:00+01:00");
const vor10 = zustellungen;
await push.laufen();
ok(zustellungen - vor10 === 2,
`an einem neuen Anlasstag geht wieder etwas raus (${zustellungen - vor10})`);
ok(zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?",
"mz:2026-11-10") === 2,
"…unter einem eigenen Merkmal fuer diesen Tag");
/* GEGENPROBE: Der 11. ist KEIN Anlasstag. Ginge auch dort etwas
heraus, hiesse „es kommt an" in Wahrheit „es kommt jeden Tag", und
der ganze Terminplan der Vorlage waere wirkungslos. */
melde("\n=== Der 11. November — kein Anlass ===");
uhrAuf("2026-11-11T09:00:00+01:00");
const vor11 = zustellungen;
await push.laufen();
ok(zustellungen === vor11,
`am 11. ist Ruhe (${zustellungen - vor11} zugestellt)`);
/* =====================================================================
5 · WER ALLES GESCHAFFT HAT
=====================================================================
Vorlage: „Glueckwunsch-Meldung, danach keine Erinnerungen mehr in
diesem Monat."
===================================================================== */
melde("\n=== Wer alles geschafft hat ===");
const dbF = new DatabaseSync(process.env.WORKSPACE_DB);
dbF.prepare("UPDATE mz_lage SET monat = '2026-11' WHERE eins = 1").run();
/* Alle Ziele dieser Rolle auf 0 -- damit ist „alles erfuellt" wahr,
ohne dass Eintraege erfunden werden muessten. Null Ziele heisst
aber auch: nichts zu gratulieren. Deshalb bekommt Offen Zwei EIN
gefordertes Ziel, das er erfuellt. */
for (const a of ["creator", "meeting", "schulung", "werbung"]) {
dbF.prepare(`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_am)
VALUES ('2026-11','manager',?,?,?)
ON CONFLICT(monat, rolle, aufgabe) DO UPDATE SET ziel = excluded.ziel`)
.run(a, a === "meeting" ? 1 : 0, new Date().toISOString());
}
dbF.prepare(`INSERT INTO mz_eintrag (person_id, von_name, von_rolle, aufgabe, monat, datum, erstellt)
VALUES (?,?,?,?,?,?,?)`)
.run(idOffen2, "Offen Zwei", "manager", "meeting", "2026-11", "2026-11-05",
new Date().toISOString());
dbF.close();
uhrAuf("2026-11-15T09:00:00+01:00");
const vor15 = zustellungen;
await push.laufen();
const glueck = zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?",
"mz:2026-11:fertig");
ok(glueck === 1, `der Fertige bekommt genau einen Glueckwunsch (${glueck})`);
ok(zustellungen - vor15 === 2,
`insgesamt zwei Meldungen an diesem Tag (${zustellungen - vor15})`
+ " — ein Glueckwunsch und eine Mahnung");
/* UND DANACH IST RUHE FUER IHN. Der 16. ist ein Anlasstag; der
Fertige darf dort nichts mehr bekommen, der andere schon. */
melde("\n=== Der 16. — Mahnung ja, Glueckwunsch nein ===");
uhrAuf("2026-11-16T09:00:00+01:00");
const vor16 = zustellungen;
await push.laufen();
const am16 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const empfaenger16 = am16.prepare(
"SELECT person_id FROM push_verschickt WHERE merkmal = ?").all("mz:2026-11-16");
am16.close();
ok(zustellungen - vor16 === 1,
`nur noch einer bekommt etwas (${zustellungen - vor16})`);
ok(empfaenger16.length === 1 && empfaenger16[0].person_id === idOffen1,
"…und zwar der, bei dem noch etwas offen ist");
ok(zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?",
"mz:2026-11:fertig") === 1,
"der Glueckwunsch bleibt bei einem — er wird nicht wiederholt");
/* =====================================================================
SCHLUSS
===================================================================== */
versatz = 0;
dienst.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);
+92 -6
View File
@@ -436,6 +436,66 @@ melde("\n=== Fertig machen und bewerten ===");
ok(fremderStatus.code === 403 || fremderStatus.code === 404, ok(fremderStatus.code === 403 || fremderStatus.code === 404,
` und wer sie nicht hat, setzt auch keinen Status (${fremderStatus.code})`); ` und wer sie nicht hat, setzt auch keinen Status (${fremderStatus.code})`);
/* ===================================================================
EINE DAUERHAFTE AUFGABE WIRD NICHT ABGEHAKT -- AUCH NICHT UEBER
DEN STATUS (02.10.2026)
VanVan im Support, Meldung #6: „Der Modi kann die dauerhafte
Aufgabe immer noch auf erledigt setzen."
Die Sperre gab es seit dem 30.09., aber nur in `/mein-stand`. Der
Statusweg kannte `dauerhaft` nicht -- und seit die Modi den Status
setzen darf (zwanzig Zeilen weiter oben), stand ihr genau der Weg
offen, der ihr an der anderen Tuer verwehrt ist. Zwei Tueren, eine
Regel, und die Regel hing nur an einer.
GEPRUEFT WERDEN BEIDE TUEREN, sonst wandert der Fehler nur. */
{
const d = await rufe("/workspace/api/aufgaben", { method: "POST", keks: chef,
body: { titel: "Neue begruessen", dauerhaft: true, zuteilung: [idAnna] } });
const idDauer = d.json?.id;
ok(d.code === 201 && Number.isInteger(idDauer),
`eine dauerhafte Aufgabe (#${idDauer})`);
await rufe(`/workspace/api/aufgaben/${idDauer}/annehmen`, { method: "POST", keks: anna });
/* ES GIBT NUR NOCH EINE TUER (03.10.2026). Hier standen zwei:
`/mein-stand` und der Status. Die erste ist weg -- sie setzte
den Zuteilungszustand, ohne den Aufgabenstatus mitzuziehen,
und war damit der zweite Zustand, den VanVans Meldung #8
benannt hat. Dass sie zu war, hat nichts geheilt; dass es sie
nicht mehr gibt, schon. */
const weg = await rufe(`/workspace/api/aufgaben/${idDauer}/mein-stand`,
{ method: "POST", keks: anna, body: { zustand: "erledigt" } });
ok(weg.code === 404,
`den zweiten Weg (mein-stand) gibt es nicht mehr (${weg.code})`);
const ueberStatus = await rufe(`/workspace/api/aufgaben/${idDauer}`,
{ method: "PATCH", keks: anna, body: { status: "erledigt" } });
ok(ueberStatus.code === 409 && ueberStatus.json?.fehler === "dauerhafte_aufgabe",
`und der Status ist zu (${ueberStatus.code} ${ueberStatus.json?.fehler})`
+ " — das war VanVans Befund");
/* ANFANGEN DARF SIE SEHR WOHL. Auch eine stehende Aufgabe hat
einen Anfang; gesperrt ist nur das Abhaken. Ohne diese Zeile
waere „gesperrt" eine Regel, die zu viel sperrt. */
const anfangen = await rufe(`/workspace/api/aufgaben/${idDauer}`,
{ method: "PATCH", keks: anna, body: { status: "arbeit" } });
ok(anfangen.code === 200, ` anfangen darf sie trotzdem (${anfangen.code})`);
/* UND DIE LEITUNG BEENDET SIE. Eine Pflicht, die niemand mehr
beenden kann, waere eine Falle statt einer Regel. */
const chefBeendet = await rufe(`/workspace/api/aufgaben/${idDauer}`,
{ method: "PATCH", keks: chef, body: { status: "erledigt" } });
ok(chefBeendet.code === 200, ` und die Leitung beendet sie (${chefBeendet.code})`);
/* DER KNOPF FEHLT AUCH AUF DEM BILDSCHIRM -- einer, der eine
Absage holt, ist schlimmer als keiner. */
const liste = await rufe("/workspace/api/aufgaben", { keks: anna });
const dAnna = (liste.json?.aufgaben || []).find((x) => x.id === idDauer);
ok(dAnna?.darf_beenden === false,
`und die Oberflaeche erfaehrt es (darf_beenden ${dAnna?.darf_beenden})`);
}
/* ZURUECKRAEUMEN -- und die Rueckfahrt ist selbst eine Messung. /* ZURUECKRAEUMEN -- und die Rueckfahrt ist selbst eine Messung.
Dieser Abschnitt hat Annas Aufgabe auf „arbeit" gestellt. Ein Dieser Abschnitt hat Annas Aufgabe auf „arbeit" gestellt. Ein
@@ -453,14 +513,40 @@ melde("\n=== Fertig machen und bewerten ===");
ok(wiederDa === "angenommen", ok(wiederDa === "angenommen",
` zurueckgedreht steht Anna wieder auf „angenommen" (${wiederDa})`); ` zurueckgedreht steht Anna wieder auf „angenommen" (${wiederDa})`);
const arbeit = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea, /* ==== BEIDE ZUSTAENDE, NICHT NUR HTTP 200 (03.10.2026) ==========
body: { zustand: "arbeit" } });
/* DER WEG BLEIBT BESTEHEN, auch ohne Knopf: Er ist die Schranke Hier stand der Weg ueber `/mein-stand` und daneben nur die
fuer den, der die Schnittstelle direkt anspricht. */ Frage, ob er mit 200 antwortet. GENAU DAS hat VanVans Fehler
durchgewunken: Die Zuteilung sprang auf „in Bearbeitung", der
Aufgabenstatus blieb auf „offen" -- und die Pruefung sagte ok,
weil sie ihn nie angesehen hat.
Jetzt geht es ueber den Status, und gemessen werden BEIDE
Seiten. Eine Pruefung, die nur eine Haelfte ansieht, kann den
Widerspruch gar nicht finden. */
const standVonBea = () => {
const x = new DatabaseSync(process.env.WORKSPACE_DB);
const a = x.prepare("SELECT status FROM aufgaben WHERE id = ?").get(idPool);
const z = x.prepare(
"SELECT zustand FROM aufgaben_zuteilung WHERE aufgabe_id = ? AND person_id = ?")
.get(idPool, idBea);
x.close();
return { status: a?.status, zustand: z?.zustand };
};
const arbeit = await rufe(`/workspace/api/aufgaben/${idPool}`, { method: "PATCH", keks: bea,
body: { status: "arbeit" } });
const beideArbeit = standVonBea();
ok(arbeit.code === 200, `Bea setzt "in Bearbeitung" (HTTP ${arbeit.code})`); ok(arbeit.code === 200, `Bea setzt "in Bearbeitung" (HTTP ${arbeit.code})`);
const fertig = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea, ok(beideArbeit.status === "arbeit" && beideArbeit.zustand === "arbeit",
body: { zustand: "erledigt" } }); ` und BEIDES steht auf "in Arbeit" (Aufgabe ${beideArbeit.status}, `
+ `Zuteilung ${beideArbeit.zustand}) — das war VanVans Befund`);
const fertig = await rufe(`/workspace/api/aufgaben/${idPool}`, { method: "PATCH", keks: bea,
body: { status: "erledigt" } });
const beideFertig = standVonBea();
ok(fertig.code === 200, `und danach "erledigt" (HTTP ${fertig.code})`); ok(fertig.code === 200, `und danach "erledigt" (HTTP ${fertig.code})`);
ok(beideFertig.status === "erledigt" && beideFertig.zustand === "erledigt",
` und wieder beides (Aufgabe ${beideFertig.status}, Zuteilung ${beideFertig.zustand})`);
/* DIE DREI RUECKMELDUNGEN AUS ABSCHNITT 5. */ /* DIE DREI RUECKMELDUNGEN AUS ABSCHNITT 5. */
const leer = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef, const leer = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef,
+96
View File
@@ -252,8 +252,55 @@ export function supportTabellen(d) {
ON support_meldungen(stand, id DESC); ON support_meldungen(stand, id DESC);
CREATE INDEX IF NOT EXISTS idx_support_person CREATE INDEX IF NOT EXISTS idx_support_person
ON support_meldungen(person_id, id DESC); ON support_meldungen(person_id, id DESC);
/* ==== MEHRERE BILDER JE MELDUNG (02.10.2026) ==================
VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufügen bei einer
Meldung. 2-3 wären besser." Und in Runde 2: „wenn man es
nacheinander versucht hinzuzufügen wird das Bild immer nur
ersetzt."
KEINE GEGEN-APOSTROPHE IN DIESEM TEXT. Er steht in einem
Template-Literal, und einer davon beendet es -- der Server
startet dann gar nicht mehr. Genau das ist in der Nacht zum
25.09. schon einmal passiert, zwanzig Zeilen weiter unten
steht die Warnung dazu, und beim Schreiben dieses Kommentars
ist es trotzdem wieder passiert. Ein Kommentar, der vor einem
Fehler warnt, verhindert ihn nicht.
EINE TABELLE FUER ALLE SUPPORTBILDER, nicht drei Spaltensaetze
nebeneinander. Die Alternative waere gewesen, bild2_datei,
bild3_datei danebenzustellen -- und beim vierten Bild wieder.
Eine Zeile je Bild kennt keine Obergrenze im Schema; die Grenze
steht an EINER Stelle im Code (BILDER_MAX) und laesst sich dort
aendern, ohne die Datenbank anzufassen.
runde_nr SAGT, WOZU ES GEHOERT: NULL heisst „zur Meldung
selbst", eine Zahl heisst „zur Antwort in dieser Runde". Damit
braucht es keine zweite Tabelle fuer die Runden -- es ist
dieselbe Frage („welches Bild gehoert hierhin?"), und zwei
Tabellen waeren zwei Antworten darauf.
DIE REIHENFOLGE IST DIE DES HOCHLADENS (die id). Eine eigene
Spalte nr waere eine Zahl, die jemand pflegen muss, sobald ein
Bild dazwischen weggeht. */
CREATE TABLE IF NOT EXISTS support_bilder (
id INTEGER PRIMARY KEY AUTOINCREMENT,
meldung_id INTEGER NOT NULL
REFERENCES support_meldungen(id) ON DELETE CASCADE,
runde_nr INTEGER,
datei TEXT NOT NULL,
typ TEXT,
breite INTEGER,
hoehe INTEGER,
erstellt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_support_bilder
ON support_bilder (meldung_id, runde_nr, id);
`); `);
/* ==== NACHTRAGEN, WAS `IF NOT EXISTS` NICHT NACHTRAEGT ============ /* ==== NACHTRAGEN, WAS `IF NOT EXISTS` NICHT NACHTRAEGT ============
(02.10.2026) (02.10.2026)
@@ -290,4 +337,53 @@ export function supportTabellen(d) {
console.error(`[support] Spalte '${spalte}':`, fehler?.message); console.error(`[support] Spalte '${spalte}':`, fehler?.message);
} }
} }
/* ==== DIE VORHANDENEN BILDER WANDERN MIT (02.10.2026) ============
Ohne diesen Schritt haette die neue Tabelle ab heute recht und
die acht Bilder von vorher waeren unsichtbar -- ein Umbau, der
Daten verliert, ohne dass etwas rot wird. Genau die Sorte, die
am 11.09. drei Spalten gekostet hat.
ERST HIER, NICHT WEITER OBEN. Gelesen wird `urteil_bild_datei`
aus `support_runden` -- und diese Spalte gibt es in einer
bestehenden Datenbank erst, nachdem die Nachruestung direkt
darueber gelaufen ist. Haette der Umzug oben gestanden, waere er
genau dort gescheitert, wo es darauf ankommt: live. Lokal waere
nichts aufgefallen, weil jede Pruefung ihre Datenbank frisch
anlegt und `CREATE TABLE` die Spalte dann gleich mitbringt.
ZWEIMAL AUSFUEHRBAR: Eingetragen wird nur, wo es noch keine Zeile
gibt. Ein zweiter Start darf nichts verdoppeln.
DIE ALTEN SPALTEN BLEIBEN STEHEN und werden ab jetzt nicht mehr
GESCHRIEBEN. Sie wegzunehmen hiesse, die Tabelle neu zu bauen --
und genau dabei gingen am 11.09. die drei Spalten verloren.
Gelesen wird ab jetzt nur noch `support_bilder`; die alten Felder
sind Geschichte, kein zweiter Weg daneben. */
try {
const n = new Date().toISOString();
const ausMeldung = d.prepare(`
INSERT INTO support_bilder (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
SELECT m.id, NULL, m.bild_datei, m.bild_typ, m.bild_breite, m.bild_hoehe,
COALESCE(m.erstellt, ?)
FROM support_meldungen m
WHERE m.bild_datei IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM support_bilder b
WHERE b.meldung_id = m.id AND b.runde_nr IS NULL
AND b.datei = m.bild_datei)`).run(n);
const ausRunde = d.prepare(`
INSERT INTO support_bilder (meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
SELECT r.meldung_id, r.nr, r.urteil_bild_datei, r.urteil_bild_typ,
r.urteil_bild_breite, r.urteil_bild_hoehe, COALESCE(r.urteil_am, ?)
FROM support_runden r
WHERE r.urteil_bild_datei IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM support_bilder b
WHERE b.meldung_id = r.meldung_id AND b.runde_nr = r.nr
AND b.datei = r.urteil_bild_datei)`).run(n);
const mit = (ausMeldung.changes || 0) + (ausRunde.changes || 0);
if (mit) console.log(`[support] ${mit} vorhandene Bilder nach support_bilder uebernommen.`);
} catch (fehler) {
console.error("[support] BILDER NICHT UEBERNOMMEN:", fehler?.message);
}
} }
+73 -1
View File
@@ -10,7 +10,7 @@
import express from "express"; import express from "express";
import { import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, darfAufgabenVerteilen, darfAufgabenAnlegen, ohneDogFather, heuteLokal, ROLLEN_SORTIERUNG, ROLLEN_GRUPPE, betreuteIds, db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, istRechteHand, darfAufgabenVerteilen, darfAufgabenAnlegen, ohneDogFather, heuteLokal, ROLLEN_SORTIERUNG, ROLLEN_GRUPPE, betreuteIds,
externPruefen, externSql, sichtbarePersonenIds, sichtbareCreatorIds, externPruefen, externSql, sichtbarePersonenIds, sichtbareCreatorIds,
MODI_KATEGORIEN, kategorienFuer, kategoriePersonen, MODI_KATEGORIEN, kategorienFuer, kategoriePersonen,
KANAELE, kanaeleFuer, KANAELE, kanaeleFuer,
@@ -374,6 +374,38 @@ function darfAendern(person, aufgabe) {
ohnehin. Die Zeile ist damit eine Erweiterung, keine Einschraenkung ohnehin. Die Zeile ist damit eine Erweiterung, keine Einschraenkung
-- nachgewiesen in pruef-aufgaben-loeschen mit einer Gegenprobe. */ -- nachgewiesen in pruef-aufgaben-loeschen mit einer Gegenprobe. */
function darfLoeschen(person, aufgabe) { function darfLoeschen(person, aufgabe) {
/* ==== DOGFATHER UND DIE RECHTE HAND: IMMER (03.10.2026) ==========
Filipe: „ich will noch dass die rechte hand und dogfather bei den
aufgaben immer die option haben aufgaben zu loeschen. die soll es
immer geben in jeder etape."
Vorher galt `darfAufgabenVerteilen && darfAendern`. Nachgemessen,
was das hiess:
DogFather (admin) -> Leitung, also schon immer ueberall.
rechte Hand (hand) -> NUR an Aufgaben, bei denen sie selbst
Erstellerin, Verantwortliche oder
Zielperson war. An allen anderen war
der Knopf weg.
Die Luecke war also allein die rechte Hand -- und zwar nicht nach
Etappe, sondern nach Zugehoerigkeit. Genau das faellt jetzt weg.
KEIN STATUS WIRD GEFRAGT, und das ist der zweite Teil des
Auftrags („in jeder etape"). Der Loeschweg hat auch vorher keinen
Status geprueft; die Oberflaeche zeigt den Stift an JEDER Karte,
unabhaengig von offen/arbeit/review/erledigt. Es gibt hier
deshalb nichts freizuschalten -- aber es muss auch nie wieder
jemand eine Statusbedingung einbauen, und darauf passt
pruef-verteilen jetzt in allen vier Etappen auf.
`istRechteHand` UND NICHT `istHand`: Letzteres umfasst auch die
LINKE Hand. Sie steht hier bewusst nicht drin -- im ganzen Haus
hat sie das kleinere Recht, und Filipe hat zwei Rollen genannt,
nicht drei. Haette ich `istHand` genommen, haette die linke Hand
das Recht lautlos mitbekommen. */
if (istLeitung(person) || istRechteHand(person)) return true;
return darfAufgabenVerteilen(person) && darfAendern(person, aufgabe); return darfAufgabenVerteilen(person) && darfAendern(person, aufgabe);
} }
@@ -544,6 +576,12 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => {
Recht -- vorher hing sie am falschen, und eine Modi sah Recht -- vorher hing sie am falschen, und eine Modi sah
gar keinen. */ gar keinen. */
r.darf_status = darfStatusSetzen(req.person, r); r.darf_status = darfStatusSetzen(req.person, r);
/* UND DARF ER SIE BEENDEN? Bei einer dauerhaften Aufgabe nur
die Leitung. Ohne diese Zeile stuende dort ein Knopf
„erledigt", der eine Absage holt -- und ein Knopf, der
eine Absage holt, ist schlimmer als keiner (so steht es
seit dem 30.09. in /mein-stand). */
r.darf_beenden = !r.dauerhaft || darfAufgabenVerteilen(req.person);
/* DIE OBERFLAECHE SOLL DEN KNOPF NUR ZEIGEN, WENN ER GEHT. /* DIE OBERFLAECHE SOLL DEN KNOPF NUR ZEIGEN, WENN ER GEHT.
Bisher entschied sie es an `darf_verteilen` -- einer Auskunft Bisher entschied sie es an `darf_verteilen` -- einer Auskunft
ueber die PERSON. Loeschen haengt aber an der AUFGABE: Die ueber die PERSON. Loeschen haengt aber an der AUFGABE: Die
@@ -1121,6 +1159,40 @@ aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res)
const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false, person: req.person }); const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false, person: req.person });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
/* ==== EINE DAUERHAFTE AUFGABE WIRD NICHT ABGEHAKT ==============
(02.10.2026 — und der Anlass ist zur Haelfte meiner)
VanVan im Support, Meldung #6: „Der Modi kann die dauerhafte
Aufgabe immer noch auf erledigt setzen."
Die Sperre gab es seit dem 30.09. — aber nur in `/mein-stand`.
Der Statusweg kannte `dauerhaft` ueberhaupt nicht. Wer die
Aufgabe ohnehin aendern durfte (etwa weil das Uebernehmen aus
dem Pool ihn verantwortlich macht), hakte sie damit einfach ab.
UND ICH HABE DAS LOCH HEUTE VERBREITERT: Seit die Modi den
Status setzen darf, stand ihr dieser Weg offen, der ihr in
`/mein-stand` ausdruecklich verwehrt ist. Zwei Tueren, eine
Regel — und die Regel hing nur an einer.
„Neue begruessen" ist nicht fertig, wenn man es einmal gemacht
hat. Sie ist keine Aufgabe, die man abarbeitet, sondern eine,
die man TUT.
BEENDET WIRD SIE VON DER LEITUNG. Das stand schon im Kommentar
in `/mein-stand` — jetzt stimmt es auch hier. Wer verteilen
darf, darf auch beenden; eine Pflicht, die niemand mehr beenden
kann, waere eine Falle statt einer Regel.
DERSELBE FEHLERCODE wie in `/mein-stand` (`dauerhafte_aufgabe`):
Die Oberflaeche uebersetzt ihn schon. Ein zweiter Code fuer
dieselbe Sache waere der, den beim naechsten Mal jemand
uebersetzt und der andere nicht. */
if (aus.status === "erledigt" && aufgabe.dauerhaft
&& !darfAufgabenVerteilen(req.person)) {
return res.status(409).json({ fehler: "dauerhafte_aufgabe" });
}
/* Zuordnungen darf nur das Management verschieben. */ /* Zuordnungen darf nur das Management verschieben. */
if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.verantwortlich_id; } if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.verantwortlich_id; }
+424 -10
View File
@@ -20,7 +20,7 @@ import express from "express";
Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */ Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { randomBytes } from "node:crypto"; import { randomBytes, createHash } from "node:crypto";
import { dateiErkennen } from "./workspace-chat.js"; import { dateiErkennen } from "./workspace-chat.js";
import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.js"; import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.js";
import { vorlagenFuer } from "./workspace-treff-vorlagen.js"; import { vorlagenFuer } from "./workspace-treff-vorlagen.js";
@@ -82,10 +82,11 @@ import {
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather, db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather,
externPruefen, externSql, externPruefen, externSql,
ohneTeamDogi, ohneAgentur, siehtModis, fuehrtTeamDogi, MODI_BEREICHE_ERLAUBT, brettHinterSeiteOffen, TEAM_DOGI_ROLLEN, ohneTeamDogi, ohneAgentur, siehtModis, fuehrtTeamDogi, MODI_BEREICHE_ERLAUBT, brettHinterSeiteOffen, TEAM_DOGI_ROLLEN,
TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN, TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN, brettAufDieserAdresse,
heuteLokal, heuteLokal,
gehoertAufDieseAdresse, gehoertAufDieseAdresse,
bereicheFuer, kanaeleFuer, KANAELE, bereicheFuer, kanaeleFuer, KANAELE,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
import { nachrichtSchicken } from "./workspace-chat.js"; import { nachrichtSchicken } from "./workspace-chat.js";
import { import {
@@ -752,6 +753,89 @@ const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
const TITEL_MAX = 160; const TITEL_MAX = 160;
const TEXT_MAX = 6000; const TEXT_MAX = 6000;
/* =====================================================================
DIE PUNKTE EINES AGENTUR-EVENTS (03.10.2026)
`event_aufgaben` und `event_regeln` sind Fliesstext, den DogFather
tippt -- eine Zeile je Punkt, meistens mit einem Aufzaehlungszeichen
davor. Dass er von Hand gesetzt wird, sieht man dem Ergebnis an: Im
echten Oktober-Event stand „·Jeden Tag Live gehen" neben
„· 33k Diamanten erreichen", einmal mit Leerzeichen, einmal ohne.
HIER WIRD DARAUS EINE LISTE. Die Oberflaeche bekommt Punkte, keinen
Absatz -- damit sie Haken daran haengen kann und die Einrueckung
nicht mehr davon abhaengt, wie jemand getippt hat.
DER SPEICHERTEXT BLEIBT UNVERAENDERT. Zerlegt wird beim LESEN, nicht
beim Schreiben: Wer das Event spaeter bearbeitet, soll genau das
wiederfinden, was er geschrieben hat. Und ein Textfeld, das beim
Speichern umformatiert wird, ist ein Textfeld, dem man nicht traut.
===================================================================== */
/* Die Zeichen, mit denen Menschen Aufzaehlungen anfangen. Sie gehoeren
nicht zum Inhalt des Punktes -- und wer heute „·" tippt und morgen
„-", meint denselben Punkt. */
const PUNKT_ZEICHEN = /^[\s]*[-–—•·*▪◦‣>+]+[\s]*/u;
/** Der Kern einer Aufgabenzeile, ohne Zierde.
* Nur fuer den Vergleich gedacht -- angezeigt wird der Originaltext. */
function punktKern(zeile) {
return String(zeile || "")
.replace(PUNKT_ZEICHEN, "")
/* Eine fuehrende Nummerierung („1.", „2)") ebenfalls weg: Wer
einen Punkt davorsetzt, verschiebt sonst alle Nummern und damit
alle Schluessel -- genau der stille Haken-Umzug, den die Tabelle
verhindern soll. */
.replace(/^\s*\d+[.)]\s*/, "")
.replace(/\s+/gu, " ")
.trim()
.toLowerCase();
}
/** Der Schluessel, unter dem ein Haken gespeichert wird.
*
* AUS DEM TEXT, NICHT AUS DER ZEILENNUMMER -- die Begruendung steht
* bei der Tabelle `event_punkte` in workspace.js. Hier nur das Wie:
* Kern bilden, davon die ersten 16 Zeichen des SHA-256. 16 Zeichen
* sind 64 Bit; bei hoechstens ein paar Dutzend Punkten je Event ist
* eine Verwechslung ausgeschlossen, und der Schluessel bleibt kurz
* genug, um in einer Adresse zu stehen. */
export function punktSchluessel(zeile) {
return createHash("sha256").update(punktKern(zeile), "utf8")
.digest("hex").slice(0, 16);
}
/** Zerlegt ein Textfeld in Punkte.
*
* LEERE ZEILEN FALLEN WEG, aber nicht die Absicht dahinter: Wer eine
* Leerzeile zwischen zwei Bloecke setzt, trennt -- und das ergibt in
* einer Liste ohnehin keinen eigenen Punkt.
*
* GLEICHE PUNKTE NUR EINMAL: Stuende „22 Live-Stunden" zweimal da,
* haetten beide denselben Schluessel. Ein Haken wuerde dann an zwei
* Stellen gleichzeitig erscheinen und die Zaehlung „2 von 3" waere
* falsch. Der zweite faellt deshalb weg -- zusammen mit der Zeile,
* denn eine doppelte Bedingung ist ohnehin ein Tippfehler. */
export function punkteAusText(text) {
const raus = [];
const gesehen = new Set();
for (const zeile of String(text || "").split(/\r?\n/)) {
const kern = punktKern(zeile);
if (!kern) continue;
const schluessel = punktSchluessel(zeile);
if (gesehen.has(schluessel)) continue;
gesehen.add(schluessel);
raus.push({
schluessel,
/* Angezeigt wird der Text OHNE das getippte Aufzaehlungszeichen:
Die Liste setzt ihr eigenes, und zwei davon nebeneinander
sehen nach einem Fehler aus. */
text: String(zeile).replace(PUNKT_ZEICHEN, "").trim(),
});
}
return raus;
}
const jetzt = () => new Date().toISOString(); const jetzt = () => new Date().toISOString();
function angemeldet(req, res, next) { function angemeldet(req, res, next) {
@@ -865,6 +949,13 @@ bereicheRouter.use("/workspace/api/bereich/:bereich", (req, res, next) => {
Faellt eines weg, haelt das andere. */ Faellt eines weg, haelt das andere. */
if (TREFF_BRETTER.includes(b) && !TREFF_ROLLEN.has(req.person?.rolle)) return nein(); if (TREFF_BRETTER.includes(b) && !TREFF_ROLLEN.has(req.person?.rolle)) return nein();
/* UND AUF DER AGENTURADRESSE GIBT ES SIE GAR NICHT (05.10.2026).
Begruendung bei `brettAufDieserAdresse` in workspace.js -- kurz:
Diese Bretter sind immer `crew`, der Adressriegel zeigt dort nur
`agentur`, also standen sie leer da und Neues verschwand sofort.
Jetzt antwortet die Seite gar nicht erst. */
if (!brettAufDieserAdresse(req.person, b)) return nein();
if (!TEAM_DOGI_ROLLEN.has(req.person?.rolle)) return next(); if (!TEAM_DOGI_ROLLEN.has(req.person?.rolle)) return next();
/* Bretter mit eigener Kachel: wer die Kachel hat, darf hinein. */ /* Bretter mit eigener Kachel: wer die Kachel hat, darf hinein. */
if (MODI_BEREICHE_ERLAUBT.has(b)) return next(); if (MODI_BEREICHE_ERLAUBT.has(b)) return next();
@@ -1199,7 +1290,119 @@ function ohneVertrauliche(regel, person, praefix) {
auch Fragen zu Aufgaben, Dateien und Terminen; ein Bereichsname hat auch Fragen zu Aufgaben, Dateien und Terminen; ein Bereichsname hat
dort nichts zu suchen. Der Kommentar eine Bildschirmseite weiter oben dort nichts zu suchen. Der Kommentar eine Bildschirmseite weiter oben
warnt woertlich davor. */ warnt woertlich davor. */
/* =====================================================================
RIEGEL 0: DAS HAUS DER ADRESSE (03.10.2026)
Filipe, 24.09.2026: "bevor du das aber machst will ich dass du
zuerst die komplette site vn der workspace seite trennst. da soll
nichts verknuepft sein. ... vermerk dass ab jetzt jeder umbau immer
getrennt abgefuehrt wird auf den zwei seiten."
WAS GEMESSEN WURDE (03.10.2026, server/_mess-haeuser.mjs, jedes
Brett x beide Adressen x vier Rollen):
AGENTUR-Adresse DogFather 18 Bretter
Manager 6 Bretter
Creator 6 Bretter
Modi kommt nicht rein (401)
CREW-Adresse DogFather 18 Bretter
Modi 13 Bretter
Manager kommt nicht rein (401)
Creator kommt nicht rein (401)
Die Anmeldung ist also dicht -- eine Agenturrolle kommt nicht auf
die Crew-Adresse und umgekehrt. Durch greift genau EINE Rolle:
DogFather. Er ist die einzige in BEIDE_HAEUSER_ROLLEN, und die
Riegel darunter fragen nach der ROLLE, nicht nach der Adresse.
Auf der Crew-Adresse stand damit das Brett der Agentur samt Inhalt.
WARUM ES BISHER NICHT GRIFF, obwohl es die Spalte `eintraege.haus`
seit dem 24.09. gibt: Sie wird beim ANLEGEN gefuellt und beim LESEN
nie gefragt. Gefiltert wurde ausschliesslich ueber die ROLLEN der
beteiligten Personen (ohneTeamDogi / ohneAgentur) -- und ein Eintrag,
an dem nur DogFather haengt, faellt durch beide Netze, weil er in
beiden Haeusern wohnt. Bei 20 von 26 Eintraegen ist er der Ersteller.
DER RIEGEL STEHT GANZ VORNE, vor der Aussenrolle: Er gilt damit fuer
JEDEN Rueckgabeweg dieser Funktion, auch fuer den, den jemand als
naechstes einbaut. Dieselbe Ueberlegung wie bei "WARUM DER KLICK
GLEICH HIER DRANHAENGT" in bereich.js -- eine Funktion mit drei
Ausgaengen braucht ihre Regel am Eingang, nicht an jedem Ausgang.
OHNE ADRESSE KEINE EINSCHRAENKUNG. `person.haus` setzt sitzungLesen()
aus dem Host-Kopf; bei einer Pruefung auf 127.0.0.1 steht dort
nichts. Dann greift dieser Riegel nicht -- sonst waeren alle
Pruefungen des Hauses still gruen und blind, genau die Falle, vor der
crew-adresse.js dreimal warnt.
UND NULL IST KEIN LOCH. Fuenf von acht Stellen, die Eintraege
anlegen, setzen `haus` gar nicht (gemessen: workspace-video.js,
workspace-content.js, workspace-bewerbung.js, workspace-treff.js,
workspace-vorlagen.js). Ein Filter, der nur `haus = ?` prueft,
wuerde deren Eintraege fuer BEIDE Haeuser verbergen -- Daten
verschwinden still, der schlimmere Ausgang. Deshalb entscheidet bei
NULL der BEREICH, und zwar nach genau derselben Regel, nach der der
naechtliche Nachtrag ihn setzt (eintraegeNachHaus in workspace.js).
Zwei Fassungen derselben Regel waeren die, die auslaeuft.
===================================================================== */
/** Die Bedingung "nur Eintraege dieses Hauses" -- oder null, wenn die
* Frage sich nicht stellt (keine Adresse, also keine Haustrennung).
*
* ==== WARUM NULL SICHTBAR BLEIBT (03.10.2026) =======================
*
* Die erste Fassung hat bei `haus IS NULL` den BEREICH entscheiden
* lassen -- nach derselben Regel, nach der eintraegeNachHaus() das
* Haus nachtraegt (MODI_BEREICHE_ERLAUBT). Das war falsch, und
* pruef-haus-trennung.mjs hat es sofort gefunden:
*
* FEHL auf workspace. steht der Eintrag da
*
* Es ging um "Lunas Live vom Montag" auf dem Brett `live`. Die
* Ableitung stuft `live` als Crew-Brett ein (Team Dogi hat eine
* Kachel darauf) -- aber dasselbe Brett traegt auch die
* Creator-Akten der Agentur. `live`, `technik` und `community`
* gehoeren BEIDEN Haeusern, und eine Regel, die jedem Brett genau
* ein Haus zuweist, kann das nicht abbilden.
*
* Sie ist als VERTEILREGEL trotzdem richtig: Wenn ein neuer Eintrag
* ein Haus braucht, ist "wer hat die Kachel" die beste Auskunft, die
* es gibt. Als AUSSCHLUSSREGEL beim Lesen ist sie es nicht -- dort
* nimmt sie etwas weg, statt etwas zuzuordnen, und ein Irrtum kostet
* Sichtbarkeit statt nur Ordnung.
*
* Deshalb: Wer ein Haus hat, wird danach gefiltert. Wer keins hat,
* bleibt sichtbar. Das ist die sichere Richtung -- ein Eintrag zu
* viel faellt auf, ein fehlender nicht.
*
* DAMIT DARAUS KEIN DAUERLOCH WIRD, zwei Dinge: Die fuenf Stellen,
* die `haus` bisher nicht gesetzt haben, tun es jetzt (gemessen am
* 03.10.2026: workspace-video.js, workspace-content.js,
* workspace-bewerbung.js, workspace-treff.js, workspace-vorlagen.js),
* und pruef-haus-luecke.mjs wird rot, sobald eine sechste dazukommt,
* die es vergisst. Was trotzdem ohne Haus entsteht, traegt
* eintraegeNachHaus() beim naechsten Start nach. */
function nurDiesesHaus(person, praefix = "e") {
const haus = person?.haus;
if (haus !== "crew" && haus !== "agentur") return null;
return {
wo: `(${praefix}.haus = ? OR ${praefix}.haus IS NULL)`,
werte: [haus],
};
}
export function sichtbarEintrag(person, praefix = "e") { export function sichtbarEintrag(person, praefix = "e") {
/* RIEGEL 0 -- das Haus der Adresse. Begruendung direkt darueber.
Er wird den uebrigen Riegeln UMGEHANGEN statt in jeden Ausgang
geschrieben: Diese Funktion hat drei Rueckgabepunkte, und der
naechste, den jemand einbaut, waere sonst wieder offen. */
const haus = nurDiesesHaus(person, praefix);
const mitHaus = (r) => {
if (!r || !haus) return r;
return { wo: `(${r.wo}) AND ${haus.wo}`, werte: [...r.werte, ...haus.werte] };
};
/* RIEGEL 1 -- die Aussenrolle. Eine eigene, vollstaendige Regel: /* RIEGEL 1 -- die Aussenrolle. Eine eigene, vollstaendige Regel:
genau die Bretter des Treffs, sonst nichts. */ genau die Bretter des Treffs, sonst nichts. */
if (AUSSEN_ROLLEN.has(person?.rolle)) { if (AUSSEN_ROLLEN.has(person?.rolle)) {
@@ -1215,19 +1418,19 @@ export function sichtbarEintrag(person, praefix = "e") {
weiterhin geliefert. Genau so ist am 10.09. ein Modi-Eintrag weiterhin geliefert. Genau so ist am 10.09. ein Modi-Eintrag
durch die Suche gekommen, obwohl das Brett zu war. */ durch die Suche gekommen, obwohl das Brett zu war. */
const frei = freigabeBedingung(person, praefix); const frei = freigabeBedingung(person, praefix);
if (!frei) return grund; if (!frei) return mitHaus(grund);
return { wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] }; return mitHaus({ wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] });
} }
const regel = sichtbarRoh2(person, praefix); const regel = sichtbarRoh2(person, praefix);
/* RIEGEL 2 -- wer nicht zum Treff gehoert, sieht seine Bretter nicht. /* RIEGEL 2 -- wer nicht zum Treff gehoert, sieht seine Bretter nicht.
Auch DogFather geht hier durch; er steht in TREFF_ROLLEN und faellt Auch DogFather geht hier durch; er steht in TREFF_ROLLEN und faellt
deshalb nicht heraus. */ deshalb nicht heraus. */
if (!regel || TREFF_ROLLEN.has(person?.rolle)) return regel; if (!regel || TREFF_ROLLEN.has(person?.rolle)) return mitHaus(regel);
const platz = TREFF_BRETTER.map(() => "?").join(", "); const platz = TREFF_BRETTER.map(() => "?").join(", ");
return { return mitHaus({
wo: `(${regel.wo}) AND ${praefix}.bereich NOT IN (${platz})`, wo: `(${regel.wo}) AND ${praefix}.bereich NOT IN (${platz})`,
werte: [...regel.werte, ...TREFF_BRETTER], werte: [...regel.werte, ...TREFF_BRETTER],
}; });
} }
function sichtbarRoh2(person, praefix = "e") { function sichtbarRoh2(person, praefix = "e") {
@@ -1525,6 +1728,73 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
} }
for (const e of eintraege) e.antworten = jeE.get(e.id) || []; for (const e of eintraege) e.antworten = jeE.get(e.id) || [];
} }
/* ==== DIE PUNKTE EINES EVENTS (03.10.2026) ====================
Aus dem Fliesstext in event_aufgaben/event_regeln wird eine
Liste, und an die Aufgaben kommen die Haken der eigenen
Person. Zerlegt wird HIER und nicht im Browser: Der Schluessel
eines Punktes entscheidet, wo ein Haken landet -- eine zweite
Rechenvorschrift dafuer waere die, die beim naechsten Umbau
auslaeuft, und zwar still.
IN EINER ABFRAGE FUER DIE GANZE LISTE, wie bei den Bildern und
den Antworten darueber. Ein Event hat ein Dutzend Punkte und
ein Brett ein Dutzend Events; einzeln gefragt waere das bei
jedem Seitenaufbau ein Schwall kleiner Abfragen.
NUR DORT, WO ES EVENTS GIBT. Auf den uebrigen Brettern sind
diese Felder leer, und eine Abfrage mit leerer Liste ist eine
Abfrage ohne Zweck. */
const events = eintraege.filter((e) => e.event_aufgaben || e.event_regeln);
if (events.length) {
const ichId = (req.sicht || req.person)?.id;
const p5 = events.map(() => "?").join(", ");
/* Meine eigenen Haken ... */
const meine = new Map();
for (const z of db().prepare(
`SELECT eintrag_id, punkt FROM event_punkte
WHERE eintrag_id IN (${p5}) AND person_id = ?`)
.all(...events.map((e) => e.id), ichId)) {
if (!meine.has(z.eintrag_id)) meine.set(z.eintrag_id, new Set());
meine.get(z.eintrag_id).add(z.punkt);
}
/* ... und fuer die Leitung: wer macht mit, und wie weit?
NUR FUER DIE LEITUNG. „Wer hat was geschafft" ist eine
Auskunft ueber andere Personen; die gehoert nicht an jede
Karte, die irgendwer aufmacht. Wer nicht zur Leitung gehoert,
bekommt das Feld gar nicht erst -- nicht leer, sondern
ueberhaupt nicht: Ein leeres Feld laedt dazu ein, es spaeter
„versehentlich" zu fuellen. */
const stand = new Map();
if (istLeitung(req.person)) {
for (const z of db().prepare(
`SELECT ep.eintrag_id, ep.person_id, p.name, COUNT(*) AS n
FROM event_punkte ep
LEFT JOIN personen p ON p.id = ep.person_id
WHERE ep.eintrag_id IN (${p5})
GROUP BY ep.eintrag_id, ep.person_id
ORDER BY n DESC, p.name`)
.all(...events.map((e) => e.id))) {
if (!stand.has(z.eintrag_id)) stand.set(z.eintrag_id, []);
stand.get(z.eintrag_id).push({ id: z.person_id, name: z.name, geschafft: z.n });
}
}
for (const e of events) {
const haken = meine.get(e.id) || new Set();
e.event_punkte = punkteAusText(e.event_aufgaben)
.map((p) => ({ ...p, meiner: haken.has(p.schluessel) }));
e.event_regelpunkte = punkteAusText(e.event_regeln);
/* DIE ZAHL KOMMT MIT, statt im Browser gezaehlt zu werden --
sie steht sonst an zwei Stellen, und „2 von 3" ist genau
die Sorte Angabe, die man nicht zweimal haben will. */
e.event_geschafft = e.event_punkte.filter((p) => p.meiner).length;
if (stand.size) e.event_stand = stand.get(e.id) || [];
}
}
} }
/* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026). /* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
@@ -2139,7 +2409,8 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/start", gleicheHerkunft, (r
/* Der TAG in Ortszeit, der ZEITSTEMPEL in UTC -- das ist kein /* Der TAG in Ortszeit, der ZEITSTEMPEL in UTC -- das ist kein
Widerspruch: Ein Zeitpunkt ist ueberall derselbe, ein Widerspruch: Ein Zeitpunkt ist ueberall derselbe, ein
Kalendertag nicht. */ Kalendertag nicht. */
heuteLokal(), jetzt(), req.person.id, req.person.haus || null); heuteLokal(), jetzt(), req.person.id,
hausFuerNeuenEintrag(req.person, bereich));
/* UND SOFORT FREIGEBEN (17.09.2026) -- der Fehler, den Filipe /* UND SOFORT FREIGEBEN (17.09.2026) -- der Fehler, den Filipe
auf seinem Bildschirm gesehen hat. auf seinem Bildschirm gesehen hat.
@@ -2260,7 +2531,7 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/:id/daraus",
„Vorbei" und war zugeklappt -- waehrend beim Wunsch „Vorbei" und war zugeklappt -- waehrend beim Wunsch
„Daraus wurde ein Termin" zu lesen war. */ „Daraus wurde ein Termin" zu lesen war. */
heuteLokal(), jetzt(), req.person.id, quelle.id, heuteLokal(), jetzt(), req.person.id, quelle.id,
req.person.haus || null); hausFuerNeuenEintrag(req.person, nach));
protokolliere("bereich_daraus", { protokolliere("bereich_daraus", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
@@ -2533,7 +2804,7 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
Fall, und der soll der Normalfall sein. */ Fall, und der soll der Normalfall sein. */
aus.nur_leitung ?? 0, aus.nur_leitung ?? 0,
/* Das Haus aus der Adresse -- letzte Spalte, letzter Wert. */ /* Das Haus aus der Adresse -- letzte Spalte, letzter Wert. */
req.person.haus || null); hausFuerNeuenEintrag(req.person, bereich));
/* DASSELBE VON HAND WIE ÜBER DEN VIDEOWEG (30.09.2026). Wer ein /* DASSELBE VON HAND WIE ÜBER DEN VIDEOWEG (30.09.2026). Wer ein
Highlight selbst anlegt und es auch freigeben dürfte, braucht Highlight selbst anlegt und es auch freigeben dürfte, braucht
@@ -2555,6 +2826,41 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
/* ---------- Ändern und Löschen ------------------------------------------- */ /* ---------- Ändern und Löschen ------------------------------------------- */
/** Darf diese Person diesen Eintrag sehen?
*
* ENTSTANDEN AM 03.10.2026, weil das Banner eines Agentur-Events fuer
* Creator mit 404 beantwortet wurde -- gemessen, nicht vermutet:
*
* workspace.dogfather-universe.com admin Bild=200
* workspace.dogfather-universe.com creator Bild=404
*
* Der Bildweg (workspace-dateien.js) kannte bisher zwei Regeln: die
* Treff-Regel ("wer das Brett sieht, sieht das Bild" -- aber nur fuer
* die sieben Community-Bretter) und sonst die ABLAGE-Regel, die an
* Creator-Zuordnungen haengt. Ein Agentur-Event ist weder das eine
* noch das andere: Es gehoert niemandem, und `agentur` ist kein
* Treff-Brett. Also fiel es durch beide.
*
* Vor dem Umbau fiel das kaum auf -- das Bild war ein Anhang unter
* dem Text. Seit es die Buehne hinter dem Titel ist, saehe eine
* Creatorin eine leere Flaeche an genau der Karte, die fuer sie
* gemacht ist.
*
* WARUM EINE FUNKTION UND KEINE ZWEITE LISTE: Der naheliegende Weg
* waere gewesen, in workspace-dateien.js `|| e.bereich === "agentur"`
* zu ergaenzen. Das waere eine zweite Stelle, an der steht, wer was
* sehen darf -- und die laeuft beim naechsten Brett aus. Hier wird
* stattdessen dieselbe Regel gefragt, die auch das Brett selbst
* benutzt (sichtbarEintrag). Was dort verborgen ist, bleibt es auch
* hier; es gibt keine Rechteausweitung, nur keine Luecke mehr. */
export function darfEintragSehen(person, eintragId) {
if (!person || !Number.isInteger(Number(eintragId))) return false;
const regel = sichtbarEintrag(person);
if (!regel) return false;
return !!db().prepare(`SELECT e.id ${VERBUND} WHERE ${regel.wo} AND e.id = ?`)
.get(...regel.werte, Number(eintragId));
}
function holen(req, id) { function holen(req, id) {
const regel = sichtbarEintrag(req.person); const regel = sichtbarEintrag(req.person);
if (!regel) return null; if (!regel) return null;
@@ -2674,6 +2980,114 @@ function darfBildAnhaengen(req, res, next) {
next(); next();
} }
/* =====================================================================
EINEN PUNKT ABHAKEN (03.10.2026)
Filipe: „ich will das viel geiler viel perfekter. und auch wenn man
die eintraegt und so soll viel mehr perfektioniert und
uebersichtlicher sein."
Ein Agentur-Event stellt Bedingungen -- „jeden Tag live",
„22 Live-Stunden", „33k Diamanten". Wer mitmacht, will sehen, wo er
steht, ohne nachzufragen. Genau das ist der Unterschied zwischen
einem Aushang und einer Aktion, an der jemand teilnimmt.
JEDER HAKT NUR FUER SICH SELBST. Es gibt bewusst keinen Weg, einen
Haken bei einer ANDEREN Person zu setzen -- auch nicht fuer die
Leitung. „Ich habe meine 22 Stunden" ist eine Aussage ueber sich,
und wer sie fuer andere treffen koennte, macht aus einer Selbstaus-
kunft eine Bewertung. Die Person steht deshalb nirgends in der
Adresse; sie kommt aus der Sitzung.
KEIN KOERPER BEIM DELETE (gemessen 11.09.2026, steht so schon in
bereich.js): Nodes HTTP-Parser weist ein DELETE mit Koerper mit
einem leeren 400 ab, bevor Express die Anfrage sieht.
===================================================================== */
function punktZugang(req, res, next) {
const id = Number(req.params.id);
const schluessel = String(req.params.punkt || "");
/* Der Schluessel ist 16 Hexzeichen -- was anders aussieht, kommt
nicht aus unserer Liste und hat in der Tabelle nichts zu suchen.
Ohne diese Pruefung koennte jemand beliebigen Text ablegen und die
Tabelle als Notizbuch missbrauchen. */
if (!Number.isInteger(id) || !/^[0-9a-f]{16}$/.test(schluessel)) {
return res.status(400).json({ fehler: "ungueltig" });
}
const eintrag = holen(req, id);
if (!eintrag) return res.status(404).json({ fehler: "nicht_gefunden" });
/* GEHOERT DER PUNKT UEBERHAUPT ZU DIESEM EVENT?
Das ist der eigentliche Grund fuer diesen Waechter. Ohne ihn
koennte man einen beliebigen gueltig aussehenden Schluessel
setzen; die Zahl „3 von 3" waere danach erreichbar, ohne dass eine
der drei Aufgaben je dastand. Eine Zaehlung, die man sich selbst
ausdenken kann, zaehlt nichts.
Gefragt wird die LISTE, nicht der Text: punkteAusText ist die eine
Stelle, die entscheidet, was ein Punkt ist. */
const punkte = punkteAusText(eintrag.event_aufgaben);
if (!punkte.some((p) => p.schluessel === schluessel)) {
return res.status(404).json({ fehler: "punkt_unbekannt" });
}
req.eventEintrag = eintrag;
req.eventPunkt = schluessel;
next();
}
bereicheRouter.put("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
try {
/* ON CONFLICT DO NOTHING und kein Fehler beim zweiten Mal: Zwei
Klicks kurz hintereinander (oder ein zweites Geraet) sind kein
Versehen, das man melden muesste -- das Ergebnis ist in beiden
Faellen „der Haken ist gesetzt". */
db().prepare(
"INSERT INTO event_punkte (eintrag_id, person_id, punkt, gesetzt)"
+ " VALUES (?,?,?,?) ON CONFLICT DO NOTHING")
.run(req.eventEintrag.id, req.person.id, req.eventPunkt, jetzt());
res.json(punktStand(req.eventEintrag, req.person.id));
} catch (fehler) {
console.error("[bereich] Punkt setzen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
bereicheRouter.delete("/workspace/api/eintrag/:id(\\d+)/punkt/:punkt",
angemeldet, gleicheHerkunft, punktZugang, (req, res) => {
try {
db().prepare(
"DELETE FROM event_punkte WHERE eintrag_id = ? AND person_id = ? AND punkt = ?")
.run(req.eventEintrag.id, req.person.id, req.eventPunkt);
res.json(punktStand(req.eventEintrag, req.person.id));
} catch (fehler) {
console.error("[bereich] Punkt loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Was nach einem Haken zurueckgeht: der gueltige Stand, vom Server.
*
* NICHT „ok". Der Browser rechnet sonst selbst weiter („war 2, jetzt
* 3") -- und liegt daneben, sobald zwei Fenster offen sind oder eine
* Aufgabe inzwischen umgeschrieben wurde. Die Zahl, die auf der Karte
* steht, kommt immer von hier. */
function punktStand(eintrag, personId) {
const punkte = punkteAusText(eintrag.event_aufgaben);
const meine = new Set(db().prepare(
"SELECT punkt FROM event_punkte WHERE eintrag_id = ? AND person_id = ?")
.all(eintrag.id, personId).map((z) => z.punkt));
return {
gesamt: punkte.length,
/* Nur zaehlen, was AUCH in der Liste steht: Wird eine Aufgabe
umgeschrieben, bleibt ihr alter Haken als Zeile liegen (das ist
richtig, siehe Tabelle) -- mitzaehlen darf er nicht. */
geschafft: punkte.filter((p) => meine.has(p.schluessel)).length,
punkte: punkte.map((p) => ({ ...p, meiner: meine.has(p.schluessel) })),
};
}
bereicheRouter.post("/workspace/api/bereich/:bereich/:id(\\d+)/bild", bereicheRouter.post("/workspace/api/bereich/:bereich/:id(\\d+)/bild",
gleicheHerkunft, gleicheHerkunft,
darfBildAnhaengen, darfBildAnhaengen,
+4 -3
View File
@@ -46,6 +46,7 @@ import express from "express";
import { import {
db, sitzungLesen, protokolliere, echteIp, istDogFather, heuteLokal, db, sitzungLesen, protokolliere, echteIp, istDogFather, heuteLokal,
ROLLEN_NAME, ROLLEN_NAME,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
import { import {
BEWERBUNG_FRAGEN, BEWERBUNG_GRUPPEN, BEWERBUNG_WEGE, BEWERBUNG_ZUSTAENDE, BEWERBUNG_FRAGEN, BEWERBUNG_GRUPPEN, BEWERBUNG_WEGE, BEWERBUNG_ZUSTAENDE,
@@ -543,13 +544,13 @@ bewerbungRouter.put("/workspace/api/bewerbung/:id/weg", express.json({ limit: "8
const einsatz = antworten.get("womit") || null; const einsatz = antworten.get("womit") || null;
const { lastInsertRowid } = db().prepare(` const { lastInsertRowid } = db().prepare(`
INSERT INTO eintraege INSERT INTO eintraege
(bereich, art, titel, text, datum, status, erstellt, erstellt_von, einsatz) (bereich, art, titel, text, datum, status, erstellt, erstellt_von, einsatz, haus)
VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?)`) VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?, ?)`)
.run(b.nenn_name, .run(b.nenn_name,
`Hat sich selbst gemeldet (Anfrage #${id}).` `Hat sich selbst gemeldet (Anfrage #${id}).`
+ (antworten.get("warum") ? `\n\nWarum: ${antworten.get("warum")}` : ""), + (antworten.get("warum") ? `\n\nWarum: ${antworten.get("warum")}` : ""),
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */ /* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */
heuteLokal(), t, req.person.id, einsatz); heuteLokal(), t, req.person.id, einsatz, hausFuerNeuenEintrag(req.person, "talente"));
eintragId = Number(lastInsertRowid); eintragId = Number(lastInsertRowid);
/* AUF „ANGESPROCHEN" und nicht auf „aufgefallen": Die ersten /* AUF „ANGESPROCHEN" und nicht auf „aufgefallen": Die ersten
+334 -11
View File
@@ -43,6 +43,150 @@ import { join, extname, basename } from "node:path";
import { mkdirSync, writeFileSync, statSync, unlinkSync, import { mkdirSync, writeFileSync, statSync, unlinkSync,
copyFileSync } from "node:fs"; copyFileSync } from "node:fs";
import { liefereDatei } from "./helfer-ausliefern.mjs"; import { liefereDatei } from "./helfer-ausliefern.mjs";
/* Die zweite Fassung jeder Sprachnachricht -- siehe
helfer-ffmpeg.mjs. Sie ist der Grund, warum Miss sie ab heute
hoeren kann (Support-Meldung #12). */
import {
zweitfassungBauen, zweitfassungPfad, brauchtZweitfassung, zweitfassungTaugt,
ZWEITFASSUNG_TYP,
} from "./helfer-ffmpeg.mjs";
/** Eine Anhangsdatei entfernen -- MITSAMT ihrer zweiten Fassung.
*
* ==== SONST BLEIBT DIE HAELFTE LIEGEN (03.10.2026) ===============
*
* Zwei Stellen loeschen Anhaenge: das Wegraeumen eines ganzen
* Gespraechs und das Zuruecknehmen einer einzelnen Nachricht. Beide
* haben bis heute genau eine Datei entfernt. Mit der Zweitfassung
* waere ab sofort bei jeder geloeschten Sprachnachricht eine
* m4a liegengeblieben -- ohne Zeile, die auf sie zeigt, und damit
* fuer immer unzuordenbar.
*
* Genau diese Luecke habe ich gestern bei den Supportbildern
* gefunden, und sie war dort schon einen Tag alt. Hier steht sie
* deshalb von Anfang an an EINER Stelle: Zwei Aufrufer koennen
* nicht auseinanderlaufen.
*
* Gibt `true` zurueck, wenn danach nichts mehr daliegt -- auch wenn
* schon vorher nichts da war (ENOENT ist kein Fehlschlag). */
function anhangWeg(datei) {
let gut = true;
for (const pfad of [join(ANHANG_ORDNER, datei), zweitfassungPfad(join(ANHANG_ORDNER, datei))]) {
try { unlinkSync(pfad); } catch (f) {
if (f?.code !== "ENOENT") {
console.error("[chat] Anhang bleibt liegen:", f?.message);
gut = false;
}
}
}
return gut;
}
/** Liegt neben dieser Tondatei schon die universelle Fassung?
*
* GEFRAGT WIRD DIE PLATTE, nicht eine Spalte in der Datenbank. Eine
* Spalte muesste gepflegt werden -- und sie waere genau dann falsch,
* wenn der Nachruestlauf gerade eine Datei erzeugt hat oder jemand
* eine von Hand entfernt. Was wirklich da ist, weiss nur der Ordner.
*
* `statSync` je Nachricht klingt nach viel; gemessen ist es ein
* Verzeichniszugriff aus dem Betriebssystem-Zwischenspeicher, und er
* faellt nur bei Toenen an -- davon gibt es im ganzen Haus sechs. */
function zweitfassungDa(art, datei) {
if (art !== "ton" || !datei) return false;
if (!brauchtZweitfassung(datei)) return false;
try {
statSync(zweitfassungPfad(join(ANHANG_ORDNER, datei)));
return true;
} catch { return false; }
}
/** Was noch keine Zweitfassung hat, bekommt eine.
*
* ==== WARUM EIN NACHRUESTLAUF UND NICHT NUR BEIM HOCHLADEN =======
*
* Beim Hochladen zu wandeln hilft ab heute. Miss' Problem sind aber
* die SECHS, die seit September liegen -- und genau die waeren mit
* einer Loesung „ab jetzt" weiter stumm geblieben. Das ist dieselbe
* Falle wie bei den Supportbildern gestern: Ein Umbau, der nur
* vorwaerts gilt, laesst den vorhandenen Bestand still zurueck.
*
* GEDECKELT. Hoechstens `hoechstens` Dateien je Durchgang -- bei
* einem Ordner mit tausend Toenen soll der Rundgang nicht eine
* Viertelstunde rechnen. Was uebrig bleibt, kommt beim naechsten
* Mal dran; der Lauf merkt sich nichts, er sieht einfach nach.
*
* Gibt zurueck, wie viele neu entstanden sind. */
export async function toeneNachruesten(hoechstens = 20) {
try {
const zeilen = db().prepare(`SELECT anhang_datei FROM chat_nachrichten
WHERE anhang_art = 'ton' AND anhang_datei IS NOT NULL AND weg_am IS NULL`).all();
let gebaut = 0;
for (const z of zeilen) {
if (gebaut >= hoechstens) break;
if (!brauchtZweitfassung(z.anhang_datei)) continue;
const pfad = join(ANHANG_ORDNER, z.anhang_datei);
try { statSync(pfad); } catch { continue; }
/* ==== EINE UNTAUGLICHE FASSUNG WIRD ERSETZT (03.10.2026) ===
Hier stand nur „gibt es schon? dann weiter". Die ersten
sechs Zweitfassungen sind aber entstanden, BEVOR das Loch am
Anfang bekannt war -- vier davon tragen es (387 s, 164 s,
134 s, 63,8 s Zeitachse fuer wenige Sekunden Ton). Mit „gibt
es schon" waeren sie fuer immer so geblieben.
`zweitfassungTaugt` fragt die Datei selbst, nicht eine
Versionsnummer: Passt ihre Zeitachse zu der Tonmenge, die
sie traegt? Was nicht passt, wird weggeraeumt und neu
gebaut -- einmal, denn danach passt es. */
const ziel = zweitfassungPfad(pfad);
let daUndGut = false;
try {
statSync(ziel);
daUndGut = zweitfassungTaugt(ziel);
if (!daUndGut) {
unlinkSync(ziel);
console.log(`[ton] Zweitfassung von ${z.anhang_datei} war unbrauchbar `
+ "(Zeitachse passt nicht zum Ton) -- wird neu gebaut.");
}
} catch { /* es gibt sie noch nicht */ }
if (daUndGut) continue;
/* NACHEINANDER, nicht alle auf einmal: Zwanzig ffmpeg-Laeufe
gleichzeitig nehmen dem Server genau die Rechenzeit weg, die
er fuer die Seite braucht. */
// eslint-disable-next-line no-await-in-loop
if (await zweitfassungBauen(pfad)) gebaut++;
}
if (gebaut) console.log(`[ton] ${gebaut} Sprachnachricht(en) zweitfassung ergaenzt.`);
return gebaut;
} catch (fehler) {
console.error("[ton] Nachruesten:", fehler?.message);
return 0;
}
}
/** Einmal kurz nach dem Start, danach alle sechs Stunden.
*
* NICHT IM STARTMOMENT -- erst soll die Seite antworten. Dieselbe
* Ueberlegung wie beim Aufbewahrungslauf: Ein ffmpeg-Durchgang im
* Startmoment macht aus einem Fehler dort einen Startfehler.
*
* SECHS STUNDEN UND NICHT EINE MINUTE: Was beim Hochladen entsteht,
* wird dort schon gewandelt. Dieser Lauf ist das Netz darunter --
* fuer die Dateien von frueher und fuer den Fall, dass eine
* Umwandlung einmal nicht geklappt hat. Haeufiger waere Rechenzeit
* fuer eine Liste, die fast immer leer ist.
*
* `unref` DAMIT ER NICHTS AM LEBEN HAELT: Ein Taktgeber ohne das
* haelt den Prozess offen, und eine Pruefung, die den Server
* einbindet, endet dann nie. Genau daran hing im Haus schon einmal
* ein dreistuendiger Stillstand. */
export function toeneNachruestenStarten() {
const ersterLauf = setTimeout(() => { toeneNachruesten().catch(() => {}); }, 120_000);
const takt = setInterval(() => { toeneNachruesten().catch(() => {}); }, 6 * 3600_000);
ersterLauf.unref?.();
takt.unref?.();
}
import { import {
db, protokolliere, echteIp, sitzungLesen, istDogFather, db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER, schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
@@ -79,6 +223,7 @@ const NAME_MAX = 80;
ein PDF mit Bildern selten über 10. */ ein PDF mit Bildern selten über 10. */
const ANHANG_MAX = 12 * 1024 * 1024; const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge"); const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
const ANHANG_NAME_MAX = 200; const ANHANG_NAME_MAX = 200;
/* EIGENE GRENZE FUER TON (23.09.2026). /* EIGENE GRENZE FUER TON (23.09.2026).
@@ -1662,6 +1807,18 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
n.antwort_auf, n.angeheftet_am, n.rudel, n.antwort_auf, n.angeheftet_am, n.rudel,
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse, n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
n.anhang_breite, n.anhang_hoehe, n.anhang_dauer, n.anhang_breite, n.anhang_hoehe, n.anhang_dauer,
/* DER DATEINAME WIRD GEBRAUCHT, ABER NICHT AUSGELIEFERT
(03.10.2026). zweitfassungDa() fragt mit ihm die
Platte; nach draussen geht er nicht -- was dort liegt,
geht niemanden etwas an, der nur zuhoeren will.
Dass er hier fehlte, war der Grund, warum die zweite
Fassung nie angeboten wurde: Die Funktion bekam nichts
und sagte brav „gibt es nicht". Gefunden hat das die
Pruefung, nicht das Lesen.
(Und KEINE Gegen-Apostrophe in diesem Text: Er steht
in einem Template-Literal, und einer davon beendet
es. Heute schon das zweite Mal.) */
n.anhang_datei, n.bearbeitet_am,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von, a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
a.anhang_art AS zitat_anhang a.anhang_art AS zitat_anhang
FROM chat_nachrichten n FROM chat_nachrichten n
@@ -1877,6 +2034,13 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
von_kachel: kachelPruefen(n.von_kachel) || autoKachelFuer(n.person_id), von_kachel: kachelPruefen(n.von_kachel) || autoKachelFuer(n.person_id),
text: n.weg_am ? null : n.text, text: n.weg_am ? null : n.text,
zurueckgenommen: !!n.weg_am, zurueckgenommen: !!n.weg_am,
/* WANN BEARBEITET -- oder null. Geht nach draussen, weil an
der Nachricht "bearbeitet" stehen soll: Ein Text, der sich
hinterher still aendert, waehrend andere schon darauf
geantwortet haben, ist ein Vertrauensproblem und kein
Komfort. An einer geloeschten steht es nicht, dort gibt es
keinen Text mehr, auf den es sich beziehen koennte. */
bearbeitet_am: n.weg_am ? null : (n.bearbeitet_am || null),
erstellt: n.erstellt, erstellt: n.erstellt,
selbst: n.person_id === req.person.id, selbst: n.person_id === req.person.id,
angeheftet: !!n.angeheftet_am, angeheftet: !!n.angeheftet_am,
@@ -1912,6 +2076,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0, breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
dauer: n.anhang_dauer || 0, dauer: n.anhang_dauer || 0,
weg: `/workspace/api/chat/anhang/${n.id}`, weg: `/workspace/api/chat/anhang/${n.id}`,
/* WO DIE ZWEITE FASSUNG LIEGT -- oder `null`, wenn es
keine gibt (Bild, PDF, oder ffmpeg hat noch nicht).
GEFRAGT WIRD DIE PLATTE, nicht eine Spalte: Eine Spalte
muesste gepflegt werden, und sie waere genau dann
falsch, wenn jemand eine Datei von Hand nachlegt oder
der Nachruestlauf gerade durchgelaufen ist. */
weg_auch: zweitfassungDa(n.anhang_art, n.anhang_datei)
? `/workspace/api/chat/anhang/${n.id}?form=mp4` : null,
} : null, } : null,
})); }));
})(), })(),
@@ -2244,6 +2416,29 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`; const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" }); writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" });
/* ==== DIE ZWEITE FASSUNG (03.10.2026) =======================
Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst
sich die Nachricht nicht abspielen." Bei allen anderen ging
es; alle sechs vorhandenen Sprachnachrichten waren WebM.
NEBENHER UND NICHT DAVOR. `await` hier hiesse: Wer eine
Sprachnachricht schickt, wartet auf ffmpeg, bevor sie im
Chat steht -- und wenn ffmpeg haengt, haengt die Nachricht.
Sie geht sofort raus; die zweite Fassung kommt Sekunden
spaeter dazu, und bis dahin spielt das Original. Genau
deshalb sagt die Antwort unten auch nichts ueber sie: Was
es noch nicht gibt, wird nicht versprochen.
NUR FUER TON. Ein Bild braucht keine zweite Fassung, und
ffmpeg auf ein PDF loszulassen waere eine Tuer, nach der
niemand gefragt hat. */
if (erkannt.art === "ton" && brauchtZweitfassung(aufPlatte)) {
zweitfassungBauen(join(ANHANG_ORDNER, aufPlatte))
.catch(() => { /* der Helfer meldet es selbst; die Nachricht
steht ohnehin schon */ });
}
const d = db(); const d = db();
const n = jetzt(); const n = jetzt();
d.prepare(`INSERT INTO chat_nachrichten d.prepare(`INSERT INTO chat_nachrichten
@@ -2269,6 +2464,12 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0, breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
dauer: dauer || 0, dauer: dauer || 0,
weg: `/workspace/api/chat/anhang/${id}`, weg: `/workspace/api/chat/anhang/${id}`,
/* Gleich nach dem Hochladen gibt es sie noch nicht -- die
Umwandlung laeuft nebenher. Dass hier `null` steht, ist
deshalb richtig und keine Luecke: Beim naechsten Laden
der Nachrichten ist sie da. */
weg_auch: zweitfassungDa(erkannt.art, aufPlatte)
? `/workspace/api/chat/anhang/${id}?form=mp4` : null,
}, },
rudel: false, rudel: false,
}; };
@@ -2508,6 +2709,17 @@ chatRouter.post("/workspace/api/chat/raeume/:id/gif", gleicheHerkunft, nurRudel,
groesse: g.groesse, breite: g.breite || 0, hoehe: g.hoehe || 0, groesse: g.groesse, breite: g.breite || 0, hoehe: g.hoehe || 0,
dauer: 0, dauer: 0,
weg: `/workspace/api/chat/anhang/${id}`, weg: `/workspace/api/chat/anhang/${id}`,
/* EIN GIF HAT KEINE ZWEITE FASSUNG, und zwar nie -- das ist
hier ein Bild, kein Ton. Die Zeile steht trotzdem da:
Faehlte sie, haette dieselbe Nachricht je nach Weg einmal
das Feld und einmal nicht, und der Browser muesste beide
Faelle kennen. Ein ausdrueckliches `null` ist eine
Auskunft, ein fehlendes Feld ist eine Frage.
(Mein erster Anlauf rief hier `zweitfassungDa(erkannt...)`
auf -- `erkannt` gibt es in dieser Route gar nicht. Das
waere ein Absturz beim ersten GIF gewesen, und zwar erst
im Betrieb.) */
weg_auch: null,
}, },
}; };
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id); const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
@@ -2545,7 +2757,29 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
return res.status(404).json({ fehler: "nicht_gefunden" }); return res.status(404).json({ fehler: "nicht_gefunden" });
} }
const pfad = join(ANHANG_ORDNER, n.anhang_datei); /* ==== DIE ZWEITE FASSUNG, WENN SIE VERLANGT WIRD (03.10.2026)
`?form=mp4` liefert die AAC-Fassung derselben Sprachnachricht.
Miss im Support, #12: Auf ihrem iPhone laesst sich WebM nicht
abspielen; dieselbe Nachricht in MP4 schon.
EINE ROUTE, KEINE ZWEITE. Es ist dieselbe Nachricht mit
denselben Rechten -- wer sie sehen darf, darf sie in beiden
Fassungen sehen. Eine eigene Route haette dieselbe
Sichtbarkeitspruefung ein zweites Mal gebraucht, und es ist
immer die zweite, die beim naechsten Umbau vergessen wird.
STILL ZURUECK AUF DAS ORIGINAL, wenn es die Fassung nicht
gibt: Wer sie anfragt, hat sie in der Nachrichtenliste
angeboten bekommen -- ist sie seitdem verschwunden, soll er
etwas hoeren und keinen Fehler lesen. */
let pfad = join(ANHANG_ORDNER, n.anhang_datei);
let typ = n.anhang_typ || "application/octet-stream";
if (req.query.form === "mp4" && n.anhang_art === "ton"
&& brauchtZweitfassung(n.anhang_datei)) {
const zweit = zweitfassungPfad(pfad);
try { statSync(zweit); pfad = zweit; typ = ZWEITFASSUNG_TYP; } catch { /* Original */ }
}
try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); } try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
/* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein /* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein
@@ -2557,7 +2791,7 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
Dokument jede Ausführung -- das ist die Sicherung für den Fall, Dokument jede Ausführung -- das ist die Sicherung für den Fall,
dass in einem PDF ein Skript steckt (PDF kann das). Bei einem dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
Bild kostet sie nichts. */ Bild kostet sie nichts. */
res.setHeader("Content-Type", n.anhang_typ || "application/octet-stream"); res.setHeader("Content-Type", typ);
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400"); res.setHeader("Cache-Control", "private, max-age=86400");
@@ -2940,11 +3174,7 @@ chatRouter.delete("/workspace/api/chat/raeume/:id/ganz", gleicheHerkunft, (req,
aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */ aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */
let dateienWeg = 0; let dateienWeg = 0;
for (const name of dateien) { for (const name of dateien) {
try { unlinkSync(join(ANHANG_ORDNER, name)); dateienWeg++; } if (anhangWeg(name)) dateienWeg++;
catch (f) {
if (f?.code === "ENOENT") dateienWeg++;
else console.error("[chat] Anhang bleibt liegen:", f?.message);
}
} }
protokolliere("chat_aufgeloest", { protokolliere("chat_aufgeloest", {
@@ -3374,6 +3604,102 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
const darfJedeNachrichtLoeschen = (person) => const darfJedeNachrichtLoeschen = (person) =>
istDogFather(person) || person?.rolle === "hand"; istDogFather(person) || person?.rolle === "hand";
/* =====================================================================
EIGENE NACHRICHT BEARBEITEN (03.10.2026)
Filipe: "die nachrichten die man in den chat reinschreibt. jeder
soll seine eigene nachricht bearbeiten koennen. diese option soll
jeder fuer seine eigene nachrichten haben die er selber verfasst
hat."
NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme (siehe
`darfJedeNachrichtLoeschen`, fuer den Notfall), und es waere
naheliegend, sie hier mitzunehmen. Das waere falsch: Eine fremde
Nachricht zu entfernen heisst "das soll hier nicht stehen". Eine
fremde Nachricht zu AENDERN heisst, jemandem Worte in den Mund zu
legen, die unter seinem Namen und seinem Bild stehen bleiben. Das
ist nicht dieselbe Befugnis in groesser, sondern eine andere.
ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim
Bearbeiten "@Anna" ergaenzt, spricht Anna damit NICHT an. Der
Grund ist die Richtung, in die es sonst schiefgeht: Entweder man
benachrichtigt nachtraeglich -- dann laesst sich jemand beliebig
oft anpingen, indem man dieselbe Nachricht immer wieder
bearbeitet -- oder man traegt die Erwaehnung still ein, und dann
steht jemand auf einer Liste, von der er nie erfaehrt. Beides ist
schlechter als die klare Regel: Ansprechen tut man mit einer neuen
Nachricht.
DER RAUM RUECKT NICHT NACH OBEN, und niemand bekommt sie als
ungelesen. Ein Bearbeiten ist keine Wortmeldung; wuerde der Raum
dadurch hochspringen, waere jede Tippfehlerkorrektur eine Stoerung
fuer alle anderen. Genau deshalb wird `letzte_am` hier NICHT
angefasst -- anders als beim Loeschen, wo es neu gerechnet werden
muss, weil die juengste Nachricht verschwunden sein kann.
===================================================================== */
chatRouter.patch("/workspace/api/chat/nachrichten/:id", gleicheHerkunft,
express.json({ limit: "8kb" }), (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
/* DIESELBEN GRENZEN WIE BEIM SENDEN. Eine zweite Rechnung waere
die, die auseinanderlaeuft -- und ein Text, der sich senden,
aber nicht bearbeiten laesst, waere eine Falle. */
const text = String(req.body?.text ?? "").trim();
if (!text) {
/* LEEREN IST LOESCHEN, und dafuer steht der Knopf daneben. Wer
hier eine leere Nachricht zuliesse, haette zwei Wege zu
derselben Sache -- und einen davon ohne Rueckfrage. */
return res.status(400).json({ fehler: "leer" });
}
if (text.length > TEXT_MAX) {
return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` });
}
const d = db();
const n = d.prepare(
"SELECT id, raum_id, person_id, text, weg_am FROM chat_nachrichten WHERE id = ?").get(id);
/* Was jemand nicht sehen darf, gibt es fuer ihn nicht: 404 statt
403 -- dieselbe Ueberlegung wie beim Loeschen eine Route
weiter unten. */
if (!n || !istDrin(n.raum_id, req.person)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (n.person_id !== req.person.id) {
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
}
if (n.weg_am) {
return res.status(409).json({ fehler: "Diese Nachricht ist zurückgenommen." });
}
/* UNVERAENDERT IST KEINE AENDERUNG. Sonst stuende "bearbeitet" an
einer Nachricht, an der niemand etwas getan hat -- und der
Vermerk waere ab da nichts mehr wert. */
if (text === n.text) {
return res.json({ ok: true, unveraendert: true });
}
const wann = new Date().toISOString();
d.prepare("UPDATE chat_nachrichten SET text = ?, bearbeitet_am = ? WHERE id = ?")
.run(text, wann, id);
/* An ALLE im Raum, aber an niemanden als Benachrichtigung (leere
Empfaengerliste) -- siehe oben. */
chatEreignis(n.raum_id,
{ id, raum_id: n.raum_id, text, bearbeitet_am: wann }, [], "geaendert");
protokolliere("chat_bearbeitet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id}`,
});
res.json({ ok: true, bearbeitet_am: wann, text });
} catch (fehler) {
console.error("[chat] Bearbeiten:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => { chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => {
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
@@ -3395,10 +3721,7 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
schon weg), ist der Verweis trotzdem fort -- andersherum bliebe schon weg), ist der Verweis trotzdem fort -- andersherum bliebe
im schlechten Fall eine Zeile ohne Datei stehen. */ im schlechten Fall eine Zeile ohne Datei stehen. */
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id); d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id);
if (dateiWeg) { if (dateiWeg) anhangWeg(dateiWeg);
try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
}
/* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende /* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende
er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem
es nichts mehr gibt. */ es nichts mehr gibt. */
+4 -3
View File
@@ -28,6 +28,7 @@ import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds,
istLeitung, istDogFather, siehtAlles, tagLokal, istLeitung, istDogFather, siehtAlles, tagLokal,
heuteLokal, heuteLokal,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
export const contentRouter = express.Router(); export const contentRouter = express.Router();
@@ -413,10 +414,10 @@ contentRouter.post("/workspace/api/content/:id/uebernehmen", gleicheHerkunft, (r
} else { } else {
const r = db().prepare(` const r = db().prepare(`
INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von) INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von, haus)
VALUES ('live','vorbereitung',?,?,?,'offen',?,?,?)`).run( VALUES ('live','vorbereitung',?,?,?,'offen',?,?,?,?)`).run(
e.titel.slice(0, 160), beschreibung, e.geplant ? e.geplant.slice(0, 10) : heute(), e.titel.slice(0, 160), beschreibung, e.geplant ? e.geplant.slice(0, 10) : heute(),
e.creator_id, jetzt(), req.person.id); e.creator_id, jetzt(), req.person.id, hausFuerNeuenEintrag(req.person, "live"));
neu = { art: "LIVE-Thema", id: Number(r.lastInsertRowid), wohin: "bereich.html?b=live" }; neu = { art: "LIVE-Thema", id: Number(r.lastInsertRowid), wohin: "bereich.html?b=live" };
} }
+26
View File
@@ -36,6 +36,9 @@ import {
ohneTeamDogi, ohneAgentur, siehtModis, ohneTeamDogi, ohneAgentur, siehtModis,
TREFF_BRETTER, TREFF_ROLLEN, TREFF_BRETTER, TREFF_ROLLEN,
} from "./workspace.js"; } from "./workspace.js";
/* Die eine Stelle, die beantwortet, wer welchen Eintrag sehen darf.
Kein Zirkel: workspace-bereiche.js kennt diese Datei nicht. */
import { darfEintragSehen } from "./workspace-bereiche.js";
export const dateienRouter = express.Router(); export const dateienRouter = express.Router();
@@ -658,6 +661,29 @@ dateienRouter.get("/workspace/api/dateien/:id/bild", (req, res) => {
const e = db().prepare("SELECT bereich FROM eintraege WHERE id = ?").get(datei.eintrag_id); const e = db().prepare("SELECT bereich FROM eintraege WHERE id = ?").get(datei.eintrag_id);
erlaubt = !!e && TREFF_BRETTER.includes(e.bereich) erlaubt = !!e && TREFF_BRETTER.includes(e.bereich)
&& TREFF_ROLLEN.has((req.sicht || req.person)?.rolle); && TREFF_ROLLEN.has((req.sicht || req.person)?.rolle);
/* ==== UND SONST: WER DEN EINTRAG SIEHT, SIEHT SEIN BILD =======
(03.10.2026, gemessen am Banner eines Agentur-Events.)
Die Zeile darueber deckt die sieben Community-Bretter ab. Ein
Agentur-Event ist keines davon -- und es gehoert auch
niemandem, also griff danach auch die Ablage-Regel nicht, die
an Creator-Zuordnungen haengt. Ergebnis, nachgemessen:
admin -> Bild 200
creator -> Bild 404
Also genau an der Karte, die fuer Creator gemacht ist, eine
leere Buehne. Vor dem Umbau war das Bild ein Anhang unter dem
Text und fiel kaum auf.
GEFRAGT WIRD DIESELBE REGEL WIE AUF DEM BRETT
(darfEintragSehen -> sichtbarEintrag). Keine zweite Liste von
Brettnamen hier: Was dort verborgen ist, bleibt auch hier
verborgen, und beim naechsten neuen Brett muss niemand daran
denken. Die Gegenprobe dazu steht in pruef-eventkarte.mjs --
ein Eintrag, den die Person NICHT sehen darf, liefert
weiterhin 404. */
if (!erlaubt) erlaubt = darfEintragSehen(req.sicht || req.person, datei.eintrag_id);
} }
if (!erlaubt) { if (!erlaubt) {
const { wo, werte } = sichtbar(req.person); const { wo, werte } = sichtbar(req.person);
+26 -32
View File
@@ -377,43 +377,37 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => {
"neue Anfrage für Modi", "bewerbungen.html", "neue Anfrage für Modi", "bewerbungen.html",
zaehle("SELECT COUNT(*) n FROM bewerbungen WHERE zustand = 'neu'", [])); zaehle("SELECT COUNT(*) n FROM bewerbungen WHERE zustand = 'neu'", []));
/* ==== BEI DIR KLINGELT NICHTS (19.09.2026) ======================= /* ==== BEI DIR KLINGELT NICHTS -- STEHT JETZT WOANDERS =========
Gemessen an diesem Tag: Von elf Zugaengen hatten SIEBEN kein Hier stand bis zum 03.10.2026 der Hinweis "Bei dir klingelt
einziges Geraet angemeldet. Bei ihnen kommt keine einzige nichts -- kein Geraet angemeldet", eingefuehrt am 19.09., als
Erinnerung an, solange die Seite zu ist -- keine Aufgabe, kein sieben von elf Zugaengen kein Geraet hatten.
Termin, kein Anruf.
Die Glocke oben rechts sagt das seit jeher korrekt ("Benach- ER HAT DAS PROBLEM NICHT GELOEST. Am 03.10. nachgemessen:
richtigungen aus") und kennt sogar den iPhone-Sonderfall. Nur immer noch 12 von 20 ohne Anmeldung, darunter die linke Hand
tippt sie niemand an: Sie ist ein stiller Schalter in einer und ein Modi. Der Grund liegt auf der Hand -- er SAGT es, aber
Leiste, kein Hinweis. Deshalb steht es jetzt dort, wo man man kann nichts tun: Er fuehrt auf anruf-probe.html, wo man
hinsieht. sich erst zurechtfinden muss.
DIESER HINWEIS GEHT NUR AN DIE PERSON SELBST, nicht an die Ersetzt durch einen Kasten auf der Startseite mit einem
Sicht. `req.person` und nicht `person`: Wenn DogFather die Lage "Anschalten"-Knopf (glocke.js, `hinweisZeichnen`). Ein
eines Scouts ansieht, waere "bei DIR klingelt nichts" eine Fingertipp statt eines Umwegs -- und er erklaert den
Aussage ueber den falschen Menschen -- und eine, die er nicht iPhone-Fall, in dem Web-Push ohne Home-Bildschirm gar nicht
loesen kann. geht. Das wusste hier niemand.
STUFE "offen", NICHT "warnung". Es ist kein Ausfall, sondern WARUM NICHT BEIDES: Der neue Kasten sitzt auf derselben Seite,
etwas, das man einmal einrichtet. Ein rotes Warnzeichen, das direkt ueber dieser Liste. Zwei Saetze zur selben Sache auf
bei sieben von elf Leuten dauerhaft oben steht, waere nach EINEM Bildschirm sind kein doppelter Schutz, sondern zwei
einer Woche unsichtbar -- und mit ihm die echten Warnungen Stellen, die irgendwann Verschiedenes behaupten -- derselbe
daneben. Grund, aus dem die Betreuungs-Auswahl am 31.08. zusammengelegt
wurde.
ER VERSCHWINDET VON SELBST, sobald ein Geraet angemeldet ist. WAS DAMIT AUFGEGEBEN IST, offen gesagt: Diese Zeile liess sich
Nichts zu quittieren, nichts, was veralten kann -- Regel 1 nicht wegklicken, der neue Kasten schon (einmalig, je Geraet).
dieser Datei. */ Eine Entscheidung von Filipe am 03.10. Die Gegensicherung ist
if (req.person?.id) { die Personenliste: Dort steht seit heute bei jeder Person, die
const geraete = zaehle( nichts bekommt, genau das -- die Leitung sieht es also
"SELECT COUNT(*) n FROM push_anmeldungen WHERE person_id = ?", [req.person.id]); weiterhin, auch wenn jemand den Kasten wegtippt. */
if (!geraete) {
dazu("kein_geraet", "offen",
"Bei dir klingelt nichts – kein Gerät angemeldet",
"anruf-probe.html", 1);
}
}
/* ---------- Manager-Ziele (02.10.2026) ---------- /* ---------- Manager-Ziele (02.10.2026) ----------
+1 -1
View File
@@ -756,7 +756,7 @@ function eintragAnlegen(person, rumpf, monat, heute) {
return { return {
fehler: "schon_eingetragen", fehler: "schon_eingetragen",
text: w.name text: w.name
? `„${w.name}" steht diesen Monat schon in deiner Liste.` ? `„${w.name}“ steht diesen Monat schon in deiner Liste.`
: "Dieses Video steht diesen Monat schon in deiner Liste.", : "Dieses Video steht diesen Monat schon in deiner Liste.",
}; };
} }
+20
View File
@@ -355,6 +355,26 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
Scouts an ihm haengen. Beides aus derselben Abfrage -- Scouts an ihm haengen. Beides aus derselben Abfrage --
eine zweite waere eine zweite Gelegenheit, dass die eine zweite waere eine zweite Gelegenheit, dass die
Liste etwas anderes sagt als die Rechte. */ Liste etwas anderes sagt als die Rechte. */
/* ERREICHT SIE UEBERHAUPT EINE BENACHRICHTIGUNG? (03.10.2026)
Am 03.10. nachgemessen: 20 aktive Personen, aber nur
8 mit einer Anmeldung. Zwoelf bekommen nichts --
darunter ein Modi und die linke Hand. Niemand konnte
das sehen, auch sie selbst nicht: Die Glocke sagt es
nur dem, der sie anschaut.
Wer etwas Wichtiges schreibt, soll nicht raten
muessen, wen es erreicht. Deshalb steht es dort, wo
ohnehin ueber Personen entschieden wird, und nicht
auf einer eigenen Seite, die niemand aufruft.
Aus DERSELBEN Abfrage wie alles andere -- eine
zweite waere eine zweite Gelegenheit, dass die Liste
etwas anderes sagt als die Wirklichkeit. */
(SELECT COUNT(*) FROM push_anmeldungen pa
WHERE pa.person_id = p.id) AS push_geraete,
(SELECT MAX(pa.zuletzt_ok) FROM push_anmeldungen pa
WHERE pa.person_id = p.id) AS push_zuletzt,
sz.manager_id, sz.manager_id,
(SELECT name FROM personen m WHERE m.id = sz.manager_id) AS manager_name, (SELECT name FROM personen m WHERE m.id = sz.manager_id) AS manager_name,
(SELECT COUNT(*) FROM scout_zuteilung z WHERE z.manager_id = p.id) AS scouts_anzahl (SELECT COUNT(*) FROM scout_zuteilung z WHERE z.manager_id = p.id) AS scouts_anzahl
+19 -3
View File
@@ -223,7 +223,21 @@ export function vapidKopf(endpunkt, paar, absender, stunden = 12) {
* soll NICHT nachtraeglich aufploppen. Deshalb bekommt er eine kurze * soll NICHT nachtraeglich aufploppen. Deshalb bekommt er eine kurze
* TTL -- ist er vorbei, verfaellt auch die Meldung. */ * TTL -- ist er vorbei, verfaellt auch die Meldung. */
export async function schicken(anmeldung, text, paar, absender, export async function schicken(anmeldung, text, paar, absender,
{ ttl = 3600, dringend = false } = {}) { { ttl = 3600, eilig = false, kurzlebig = false,
...rest } = {}) {
/* DER ALTE NAME MUSS KRACHEN, NICHT DURCHRUTSCHEN (03.10.2026).
Bis heute hiess beides zusammen `dringend`. Wer den alten Namen
weitergibt, bekaeme sonst klaglos `Urgency: normal` -- also genau
den Fehler zurueck, der gerade behoben wurde, und niemand saehe
es. Ein unbekannter Schluessel ist hier deshalb ein Absturz. */
const unbekannt = Object.keys(rest);
if (unbekannt.length) {
throw new Error(
`schicken(): unbekannte Angabe ${unbekannt.join(", ")} -- ` +
`aus "dringend" wurden "eilig" (weckt das Geraet) und ` +
`"kurzlebig" (verfaellt nach 150 s).`);
}
try { try {
const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth); const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth);
const kopf = vapidKopf(anmeldung.endpunkt, paar, absender); const kopf = vapidKopf(anmeldung.endpunkt, paar, absender);
@@ -232,8 +246,10 @@ export async function schicken(anmeldung, text, paar, absender,
method: "POST", method: "POST",
headers: { headers: {
...kopf, ...kopf,
TTL: String(dringend ? Math.min(ttl, 150) : ttl), /* Haltbarkeit und Dringlichkeit sind zwei Fragen -- siehe
Urgency: dringend ? "high" : "normal", `eiligRegel` in workspace-push.js. */
TTL: String(kurzlebig ? Math.min(ttl, 150) : ttl),
Urgency: eilig ? "high" : "normal",
}, },
body: koerper, body: koerper,
/* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */ /* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */
+127 -21
View File
@@ -113,19 +113,19 @@ const jetzt = () => new Date().toISOString();
export const ARTEN = [ export const ARTEN = [
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true }, { schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fällig", vorgabe: true },
{ schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true }, { schluessel: "aufgabe_ueberfaellig", name: "Aufgabe ist überfällig", vorgabe: true },
{ schluessel: "termin_gleich", name: "Termin in einer Stunde", vorgabe: true }, { schluessel: "termin_gleich", eilig: true, name: "Termin in einer Stunde", vorgabe: true },
/* DER WECKER (07.09.2026). Bewusst eine EIGENE Art neben /* DER WECKER (07.09.2026). Bewusst eine EIGENE Art neben
"termin_gleich": Die eine ist eine Vorgabe des Hauses fuer jeden "termin_gleich": Die eine ist eine Vorgabe des Hauses fuer jeden
Termin, der andere ist eine Entscheidung DIESER Person fuer DIESEN Termin, der andere ist eine Entscheidung DIESER Person fuer DIESEN
Termin. Wer die feste Stunden-Erinnerung abschaltet, will damit Termin. Wer die feste Stunden-Erinnerung abschaltet, will damit
nicht seine selbst gesetzten Wecker verlieren. */ nicht seine selbst gesetzten Wecker verlieren. */
{ schluessel: "termin_wecker", name: "Selbst gesetzter Wecker", vorgabe: true }, { schluessel: "termin_wecker", eilig: true, name: "Selbst gesetzter Wecker", vorgabe: true },
{ schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true }, { schluessel: "protokoll_fehlt", name: "Gespräch ohne Protokoll", vorgabe: true },
{ schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true }, { schluessel: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
/* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art, /* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art,
die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht
zuerst sie. */ zuerst sie. */
{ schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true }, { schluessel: "chat_nachricht", eilig: true, name: "Neue Nachricht im Chat", vorgabe: true },
/* ---- ANGESPROCHEN WERDEN IST EINE EIGENE ART (20.09.2026) ------ /* ---- ANGESPROCHEN WERDEN IST EINE EIGENE ART (20.09.2026) ------
Filipe: "ich will das man die leute mit @ markieren kann im chat." Filipe: "ich will das man die leute mit @ markieren kann im chat."
@@ -144,7 +144,7 @@ export const ARTEN = [
KEINE Ausnahme von der Ruhezeit: Ein Anruf wartet auf eine KEINE Ausnahme von der Ruhezeit: Ein Anruf wartet auf eine
Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird, Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird,
liest es um sieben -- das reicht. */ liest es um sieben -- das reicht. */
{ schluessel: "chat_erwaehnung", name: "Jemand erwähnt dich im Chat (@)", vorgabe: true }, { schluessel: "chat_erwaehnung", eilig: true, name: "Jemand erwähnt dich im Chat (@)", vorgabe: true },
/* ---- BEWERBUNGEN (23.09.2026) ----------------------------------- /* ---- BEWERBUNGEN (23.09.2026) -----------------------------------
@@ -189,7 +189,7 @@ export const ARTEN = [
NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
nachts; die Ruhezeit gilt. */ nachts; die Ruhezeit gilt. */
{ schluessel: "support", { schluessel: "support", eilig: true,
name: "Support: neue Meldung oder Antwort darauf", vorgabe: true }, name: "Support: neue Meldung oder Antwort darauf", vorgabe: true },
/* ---- MANAGER-ZIELE (02.10.2026) --------------------------------- /* ---- MANAGER-ZIELE (02.10.2026) ---------------------------------
@@ -224,7 +224,7 @@ export const ARTEN = [
Eine eigene Art und nicht "termin_gleich": Das eine ist ein Eine eigene Art und nicht "termin_gleich": Das eine ist ein
Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das
eine stumm stellt, meint selten das andere. */ eine stumm stellt, meint selten das andere. */
{ schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true, { schluessel: "dogfather_live", eilig: true, name: "DogFather ist live", vorgabe: true,
/* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um /* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um
22:05 interessant. Mit der alten Regel waere er stumm geblieben, 22:05 interessant. Mit der alten Regel waere er stumm geblieben,
und die Meldung, die Filipe ausdruecklich bestellt hat („die und die Meldung, die Filipe ausdruecklich bestellt hat („die
@@ -257,7 +257,7 @@ export const ARTEN = [
`ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um `ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um
22:30 ist der Normalfall, keine Ausnahme. */ 22:30 ist der Normalfall, keine Ausnahme. */
{ schluessel: "reaktion_live", { schluessel: "reaktion_live", eilig: true,
name: "Die Reaction geht auf Sendung", vorgabe: true, ruhe: "spaet" }, name: "Die Reaction geht auf Sendung", vorgabe: true, ruhe: "spaet" },
/* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------ /* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------
@@ -283,7 +283,7 @@ export const ARTEN = [
ist kein Telefon. Wer nachts nicht gestoert werden will, schaltet ist kein Telefon. Wer nachts nicht gestoert werden will, schaltet
jetzt diese Art ab -- eine Entscheidung, die man selbst trifft, jetzt diese Art ab -- eine Entscheidung, die man selbst trifft,
statt einer Regel, die man nicht kennt. */ statt einer Regel, die man nicht kennt. */
{ schluessel: "anruf", name: "Jemand ruft an", vorgabe: true, ruhe: "nie" }, { schluessel: "anruf", eilig: true, name: "Jemand ruft an", vorgabe: true, ruhe: "nie" },
/* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung /* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung
einmal am tag zu aktivieren wenn noch aufgaben auf sind." einmal am tag zu aktivieren wenn noch aufgaben auf sind."
@@ -323,10 +323,10 @@ export const ARTEN = [
IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint
auf einem Sperrbildschirm, den auch andere sehen. */ auf einem Sperrbildschirm, den auch andere sehen. */
{ schluessel: "hilfe", name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true }, { schluessel: "hilfe", eilig: true, name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
/* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die /* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die
stellt man sich auch um 23 Uhr. */ stellt man sich auch um 23 Uhr. */
{ schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" }, { schluessel: "test", eilig: true, name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" },
]; ];
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel)); const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
@@ -382,6 +382,49 @@ export function ruheRegel(art) {
return ARTEN.find((a) => a.schluessel === art)?.ruhe || "immer"; return ARTEN.find((a) => a.schluessel === art)?.ruhe || "immer";
} }
/** Ist diese Art EILIG -- also wertlos, wenn sie zu spaet kommt?
*
* WARUM DAS EINE EIGENE FRAGE IST (03.10.2026, nach Dienes Meldung).
*
* Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
* wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."
* Das war kein Fehler an ihrem Geraet und keiner am Service Worker --
* es war der Kopf `Urgency`, den wir mitschicken.
*
* Beide Push-Dienste behandeln "normal" ausdruecklich als "darf
* warten": Android/FCM haelt solche Meldungen zurueck, solange das
* Telefon doest, und stellt sie zu, wenn es aufwacht -- typischerweise,
* wenn jemand das Geraet entsperrt oder die App oeffnet. Apple/APNs
* nennt es "verzoegert, gebuendelt oder gedrosselt". Beschrieben ist
* damit genau Dienes Satz, Wort fuer Wort.
*
* Bis hierher hing die Dringlichkeit an der RUHEREGEL:
*
* const dringend = regel === "nie";
*
* Daneben stand die Begruendung, eine zweite Bedingung sei die, "die
* beim naechsten Nachschaerfen auseinanderlaeuft". Die Vorhersage war
* richtig, nur in die andere Richtung: Die zwei Fragen haben eben
* NICHT dieselbe Antwort.
*
* "Darf das nachts stoeren?" -> Chat: nein.
* "Darf das warten?" -> Chat: auch nein.
*
* Eine Nachricht um drei Uhr nachts soll schweigen -- um zwei Uhr
* nachmittags aber nicht vierzig Minuten liegen bleiben. Deshalb zwei
* Felder, aber in DERSELBEN Liste: auseinanderlaufen koennen sie nur,
* wenn sie an zwei Orten stehen.
*
* MASSSTAB: eilig ist, was durch Warten WERTLOS wird -- ein Anruf, ein
* Wecker, eine Nachricht, auf die jemand wartet, ein "wir sind live".
* Nicht eilig ist, was auch spaeter noch stimmt -- eine Aufgabe, die
* morgen faellig ist, der Tagesruf, eine Bewerbung. Waere alles eilig,
* waere nichts mehr eilig: Die Geraete duerfen uns die Dringlichkeit
* glauben, sonst drosseln sie uns -- und der Akku zahlt es mit. */
export function eiligRegel(art) {
return ARTEN.find((a) => a.schluessel === art)?.eilig === true;
}
/** Ist gerade Ruhezeit -- fuer diese Regel? /** Ist gerade Ruhezeit -- fuer diese Regel?
* *
* `regel` steht hinten und hat eine Vorgabe, damit vorhandene * `regel` steht hinten und hat eine Vorgabe, damit vorhandene
@@ -571,7 +614,48 @@ export function hausFuerMeldung(personId, ziel) {
} }
} }
export async function benachrichtige(personId, art, { titel, text, ziel, merkmal, zahl }) { /** Verschickt eine Meldung -- und schreibt auf, was dabei herauskam.
*
* WARUM DAS PROTOKOLL AUSSEN SITZT (03.10.2026).
*
* `versenden` hat sieben Ausgaenge: unbekannte_art, abgeschaltet,
* ruhezeit, schon_geschickt, keine_geraete, nicht_zugestellt, ok.
* Haette jeder davon seine eigene Protokollzeile, waere das eine
* Liste zum Pflegen -- und der achte Ausgang, den jemand naechstes
* Jahr einbaut, wuerde sie stillschweigend umgehen. Genau so sind
* die Spalten beim Tabellenumbau am 11.09. verlorengegangen.
*
* Hier wird deshalb nichts aufgezaehlt: Was `versenden` auch immer
* zurueckgibt, wird festgehalten. Ein neuer Ausgang steht ohne
* Zutun mit drin.
*
* UND WARUM UEBERHAUPT: Als Diene meldete, sie bekomme nichts,
* konnte niemand nachsehen, ob je etwas hinausging. Die Frage
* "warum kommt es nicht an?" setzt voraus, dass "wurde es
* abgeschickt?" schon beantwortet ist -- im Shop hat das Ueberspringen
* dieser Reihenfolge am 06.09. zwei Stunden gekostet.
*
* Das Schreiben darf den Versand nie aufhalten: Eine Meldung, die
* ankommt, ist wichtiger als die Notiz darueber. Deshalb steht es
* in einem try -- aber mit Ausgabe, denn ein Protokoll, das
* heimlich nichts schreibt, ist schlimmer als keins. */
export async function benachrichtige(personId, art, sache) {
const e = await versenden(personId, art, sache);
try {
db().prepare(
"INSERT INTO push_versand (zeit, person_id, art, grund, geraete, zugestellt) "
+ "VALUES (?,?,?,?,?,?)")
.run(jetzt(), personId, art, e.grund || "unbekannt",
e.geraete || 0, e.verschickt || 0);
} catch (fehler) {
console.error("[push] Versand nicht protokolliert:", fehler?.message || fehler);
}
return e;
}
/* Der eigentliche Versand. Umhuellt von `benachrichtige` -- siehe
dort, warum das Protokoll aussen sitzt und nicht hier drin. */
async function versenden(personId, art, { titel, text, ziel, merkmal, zahl }) {
if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" }; if (!ARTEN_SCHLUESSEL.has(art)) return { verschickt: 0, grund: "unbekannte_art" };
if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" }; if (!willHaben(personId, art)) return { verschickt: 0, grund: "abgeschaltet" };
@@ -598,7 +682,21 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
schlafendes Geraet dafuer aufwacht. Das gilt fuer den Anruf und schlafendes Geraet dafuer aufwacht. Das gilt fuer den Anruf und
die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn
das Geraet ohnehin wach wird, und keinen Akku dafuer verbrennen. */ das Geraet ohnehin wach wird, und keinen Akku dafuer verbrennen. */
const dringend = regel === "nie"; const eilig = eiligRegel(art);
/* KURZLEBIG IST NICHT DASSELBE WIE EILIG -- und das Zusammenlegen
waere hier der teure Fehler gewesen (03.10.2026).
Bis heute steuerte EIN Schalter beides: `dringend` hat das Geraet
geweckt UND die Haltbarkeit auf 150 Sekunden gesetzt. Haette ich
den Chat einfach auf `dringend` gestellt, waere aus "kommt vierzig
Minuten zu spaet" ein "kommt gar nicht mehr" geworden: Wer sein
Telefon drei Minuten aus hat, haette die Nachricht dann verloren.
Schlimmer als der Fehler, den ich beheben wollte.
Kurz haltbar bleibt deshalb genau das, was von selbst vorbei ist
-- der Anruf, der 120 Sekunden klingelt, und die Probe, bei der
jemand davorsteht. Das ist unveraendert `ruhe: "nie"`. */
const kurzlebig = regel === "nie";
/* Schon einmal geschickt? */ /* Schon einmal geschickt? */
if (merkmal) { if (merkmal) {
@@ -633,14 +731,7 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
let raus = 0; let raus = 0;
for (const a of anmeldungen) { for (const a of anmeldungen) {
/* EIN ANRUF IST DRINGEND, ALLES ANDERE NICHT (19.09.2026). const e = await schicken(a, inhalt, paar, ABSENDER, { eilig, kurzlebig });
`dringend` ist hier dasselbe `art === "anruf"`, das zwanzig
Zeilen weiter oben schon ueber die Ruhezeit entscheidet -- die
Frage „darf das nachts stoeren?" und „darf das warten?" haben
dieselbe Antwort. Eine zweite Bedingung waere die, die beim
naechsten Nachschaerfen auseinanderlaeuft. */
const e = await schicken(a, inhalt, paar, ABSENDER, { dringend });
if (e.ok) { if (e.ok) {
raus++; raus++;
db().prepare("UPDATE push_anmeldungen SET zuletzt_ok = ?, fehler = 0 WHERE endpunkt = ?") db().prepare("UPDATE push_anmeldungen SET zuletzt_ok = ?, fehler = 0 WHERE endpunkt = ?")
@@ -665,7 +756,7 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
Nachricht aufmachen zu muessen -- derselbe Gedanke wie beim Nachricht aufmachen zu muessen -- derselbe Gedanke wie beim
Ziel. */ Ziel. */
return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt", return { verschickt: raus, grund: raus ? "ok" : "nicht_zugestellt",
ziel: zielEcht, haus }; geraete: anmeldungen.length, ziel: zielEcht, haus };
} }
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
@@ -1007,6 +1098,21 @@ export async function laufen() {
mehr, und die Tabelle soll nicht ewig wachsen. */ mehr, und die Tabelle soll nicht ewig wachsen. */
const grenze = new Date(Date.now() - 60 * 86400_000).toISOString(); const grenze = new Date(Date.now() - 60 * 86400_000).toISOString();
d.prepare("DELETE FROM push_verschickt WHERE zeit < ?").run(grenze); d.prepare("DELETE FROM push_verschickt WHERE zeit < ?").run(grenze);
/* Das Sendeprotokoll ebenso -- 30 Tage.
Die Frist ist kuerzer als oben, weil die beiden Tabellen
verschiedene Fragen beantworten: push_verschickt verhindert
Doppelmeldungen und muss deshalb so lange halten wie die Sache
selbst. Das Protokoll beantwortet "kam letzte Woche etwas an?"
-- wer danach noch fragt, fragt nach etwas anderem.
Gemessen statt geschaetzt: rund 16 Chat-Nachrichten am Tag an
bis zu acht angemeldete Geraete, also hoechstens ein paar
tausend Zeilen im Monat. Die Tabelle bleibt klein genug, dass
niemand sie je bemerkt. */
d.prepare("DELETE FROM push_versand WHERE zeit < ?")
.run(new Date(Date.now() - 30 * 86400_000).toISOString());
} catch (fehler) { } catch (fehler) {
console.error("[push] Lauf:", fehler?.message); console.error("[push] Lauf:", fehler?.message);
} }
+315 -113
View File
@@ -64,6 +64,19 @@ const ANTWORT_MAX = 2000;
* Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */ * Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */
const BILD_MAX = 12 * 1024 * 1024; const BILD_MAX = 12 * 1024 * 1024;
/** Wie viele Bilder an EINE Meldung oder EINE Antwort dürfen.
*
* VanVan im Support, Meldung #11 — viermal gemeldet: „Hier im
* Supportbereich kann man immer nur ein Bild hinzufügen bei einer
* Meldung. 2-3 wären besser." Also drei, ihre eigene Obergrenze.
*
* DIE ZAHL STEHT AN EINER STELLE und nirgends sonst. Im Schema gibt
* es sie nicht (eine Zeile je Bild kennt keine Grenze), in der
* Oberfläche wird sie vom Server geholt. Eine zweite Zahl im Browser
* wäre die, die beim Erhöhen vergessen wird — genau das Muster, das
* am 06.09. den Umbruch bei 380 px gekostet hat. */
const BILDER_MAX = 3;
/** Wie viele offene Meldungen eine Person gleichzeitig haben darf. /** Wie viele offene Meldungen eine Person gleichzeitig haben darf.
* *
* Zehn, und damit deutlich großzügiger als die drei im vertraulichen * Zehn, und damit deutlich großzügiger als die drei im vertraulichen
@@ -100,6 +113,160 @@ function textSauber(s, max) {
.slice(0, max); .slice(0, max);
} }
/* =====================================================================
MEHRERE BILDER — EIN RUMPF, DIE LÄNGEN IM KOPF (02.10.2026)
=====================================================================
VanVan, Meldung #11: „2-3 wären besser." Und in der zweiten Runde
der Grund, warum der erste Versuch nicht reichte: „wenn man es
nacheinander versucht hinzuzufügen wird das Bild immer nur
ersetzt."
WIE DREI BILDER IN EINE ANFRAGE KOMMEN. Bisher war der Rumpf EIN
Bild — roh, ohne Verpackung, und `dateiErkennen` sah an den ersten
Bytes, was es ist. Für mehrere braucht es eine Grenze dazwischen.
Drei Wege standen zur Wahl:
1. `multipart/form-data`. Der übliche Weg — und er braucht einen
Zerleger, den dieses Haus nicht hat. Ein Paket mehr, um eine
Zahl zu übertragen, die auch in einen Kopf passt.
2. Drei Anfragen nacheinander. Das ist genau der Zustand, gegen
den die Kommentare bei `POST /support` schon argumentieren:
eine Meldung, die es gibt, während Bild zwei noch lädt — und
bei Abbruch für immer unvollständig.
3. DIE LÄNGEN IM KOPF, die Bilder hintereinander im Rumpf.
Es ist 3 geworden: `x-bilder: 20481,15320` sagt, wo zu schneiden
ist, und der Rumpf ist die Aneinanderreihung. Dieselbe Linie wie
bisher („Text im Kopf, Bild im Rumpf"), nur eine Angabe weiter.
EINE FALSCHE LÄNGE KANN NICHTS KAPUTTMACHEN. Die Summe muss auf
das Byte stimmen, sonst 400 — und jedes Stück wird danach einzeln
von `dateiErkennen` an seinen ersten Bytes gemessen. Wer falsch
schneidet, bekommt kein verfälschtes Bild, sondern eine Absage.
OHNE DEN KOPF IST ES DER ALTE WEG — ein Bild, der ganze Rumpf.
Das ist keine zweite Tür, sondern derselbe Satz mit einer Länge:
`[rumpf.length]`. Dadurch funktioniert eine Seite, die noch im
Zwischenspeicher des Browsers liegt, unverändert weiter. */
/** Den Rumpf in einzelne Bilder zerlegen.
*
* Gibt `{ teile }` oder `{ fehler: { code, text } }` zurück — nie
* beides und nie `null` für „ging schief". Der Aufrufer soll den
* Fehler weiterreichen können, ohne ihn selbst zu formulieren;
* sonst steht derselbe Satz an zwei Routen und altert einmal. */
function rumpfZerlegen(req) {
const rumpf = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!rumpf.length) return { teile: [] };
const kopf = String(req.get("x-bilder") || "").trim();
let laengen;
if (!kopf) {
laengen = [rumpf.length];
} else {
laengen = kopf.split(",").map((s) => Number(s.trim()));
if (laengen.some((n) => !Number.isInteger(n) || n <= 0)) {
return { fehler: { code: 400, text: "Die Bildlängen sind unbrauchbar." } };
}
}
if (laengen.length > BILDER_MAX) {
return {
fehler: {
code: 400,
text: `Mehr als ${BILDER_MAX} Bilder gehen nicht – nimm die wichtigsten.`,
},
};
}
if (laengen.some((n) => n > BILD_MAX)) {
return { fehler: { code: 413, text: "Ein Bild ist größer als 12 MB." } };
}
const summe = laengen.reduce((a, b) => a + b, 0);
/* AUF DAS BYTE. Ein „ungefähr" hieße, dass das letzte Bild
abgeschnitten ankommt und trotzdem gespeichert wird. */
if (summe !== rumpf.length) {
return { fehler: { code: 400, text: "Die Bilder sind unterwegs beschädigt worden." } };
}
const teile = [];
let ab = 0;
for (const n of laengen) { teile.push(rumpf.subarray(ab, ab + n)); ab += n; }
return { teile };
}
/** Die Stücke prüfen und auf die Platte legen.
*
* Gibt `{ bilder }` oder `{ fehler }`. Was schon geschrieben wurde,
* räumt sie bei einem späteren Fehlschlag SELBST weg — sonst
* sammelt der Ordner Dateien an, auf die keine Zeile zeigt und die
* deshalb niemand je wieder zuordnen kann. */
function bilderAblegen(teile) {
const bilder = [];
try {
for (const stueck of teile) {
const erkannt = dateiErkennen(stueck);
/* NUR BILDER. `dateiErkennen` kennt auch PDF und Ton — hier geht
es um Bildschirmfotos, und alles andere wäre eine Tür, nach
der niemand gefragt hat. */
if (!erkannt || erkannt.art !== "bild") {
for (const b of bilder) {
try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* egal */ }
}
return {
fehler: {
code: 415,
text: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
},
};
}
mkdirSync(BILD_ORDNER, { recursive: true });
const datei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(BILD_ORDNER, datei), stueck);
bilder.push({
datei, typ: erkannt.typ,
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
});
}
} catch (fehler) {
for (const b of bilder) {
try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* egal */ }
}
throw fehler;
}
return { bilder };
}
/** Die Zeilen schreiben — zur Meldung (`rundeNr === null`) oder zu
* einer Antwort. EINE Tabelle für beides, siehe support-tabellen.js. */
function bilderEintragen(meldungId, rundeNr, bilder, wann) {
const satz = db().prepare(`INSERT INTO support_bilder
(meldung_id, runde_nr, datei, typ, breite, hoehe, erstellt)
VALUES (?,?,?,?,?,?,?)`);
for (const b of bilder) {
satz.run(meldungId, rundeNr, b.datei, b.typ, b.breite, b.hoehe, wann);
}
}
/** Die Bilder einer Meldung oder einer Runde, in der Reihenfolge des
* Hochladens.
*
* DER WEG, NICHT DER DATEINAME. Was auf der Platte liegt, geht
* niemanden etwas an, der nur ein Bild anzeigen will — dieselbe
* Entscheidung wie beim Chat-Anhang. */
function bilderVon(meldungId, rundeNr) {
const zeilen = rundeNr === null
? db().prepare(`SELECT id, breite, hoehe FROM support_bilder
WHERE meldung_id = ? AND runde_nr IS NULL ORDER BY id`).all(meldungId)
: db().prepare(`SELECT id, breite, hoehe FROM support_bilder
WHERE meldung_id = ? AND runde_nr = ? ORDER BY id`).all(meldungId, rundeNr);
return zeilen.map((z) => ({
id: z.id,
weg: `/workspace/api/support/${meldungId}/bild/${z.id}`,
breite: z.breite || 0,
hoehe: z.hoehe || 0,
}));
}
/** Darf diese Person diese Meldung sehen? /** Darf diese Person diese Meldung sehen?
* *
* Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe * Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe
@@ -153,13 +320,15 @@ function nachDraussen(m, fuerLeitung, werFragtId) {
geaendert: m.geaendert, geaendert: m.geaendert,
erledigt_am: m.erledigt_am || null, erledigt_am: m.erledigt_am || null,
seite: m.seite || null, seite: m.seite || null,
/* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt /* ==== EINE LISTE, AUCH WENN NUR EINES DRAN HÄNGT (02.10.2026) ==
jede Stelle im Browser denselben Pfad zusammen, und eine davon
setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim Hier standen `bild`, `bild_breite`, `bild_hoehe` — drei Felder
Chat-Anhang. */ für genau ein Bild. Daneben eine Liste zu stellen hieße: zwei
bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null, Auskünfte auf dieselbe Frage, und die zweite wird irgendwann
bild_breite: m.bild_breite || 0, nicht mehr mitgepflegt. Deshalb nur noch `bilder` — leer,
bild_hoehe: m.bild_hoehe || 0, eines oder bis zu drei. Die Oberfläche schleift darüber und
muss den Sonderfall „genau eines" nicht kennen. */
bilder: bilderVon(m.id, null),
von: m.von_name || null, von: m.von_name || null,
von_rolle: m.von_rolle || null, von_rolle: m.von_rolle || null,
/* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet /* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet
@@ -191,18 +360,16 @@ function nachDraussen(m, fuerLeitung, werFragtId) {
/** Der Verlauf einer Meldung -- eine Zeile je Runde. */ /** Der Verlauf einer Meldung -- eine Zeile je Runde. */
function verlaufVon(id) { function verlaufVon(id) {
return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am, return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am
urteil_bild_datei
FROM support_runden WHERE meldung_id = ? ORDER BY nr`).all(id) FROM support_runden WHERE meldung_id = ? ORDER BY nr`).all(id)
.map((r) => ({ .map((r) => ({
nr: r.nr, antwort: r.antwort, von: r.von_name || null, am: r.am, nr: r.nr, antwort: r.antwort, von: r.von_name || null, am: r.am,
urteil: r.urteil || null, urteil_text: r.urteil_text || null, urteil: r.urteil || null, urteil_text: r.urteil_text || null,
urteil_am: r.urteil_am || null, urteil_am: r.urteil_am || null,
/* DER WEG, NICHT DER DATEINAME. Wie beim Bild der Meldung eine /* AUCH HIER EINE LISTE und dieselbe Form wie bei der Meldung —
Zeile weiter oben: Was auf der Platte liegt, geht niemanden die Oberfläche hat dadurch EINE Funktion für beide Stellen
etwas an, der nur ein Bild anzeigen will. */ statt zweier, die auseinanderlaufen. */
urteil_bild: r.urteil_bild_datei bilder: bilderVon(id, r.nr),
? `/workspace/api/support/${id}/runde/${r.nr}/bild` : null,
})); }));
} }
@@ -241,6 +408,11 @@ supportRouter.get("/workspace/api/support", (req, res) => {
: db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen : db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n, WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n,
hoechstens_bytes: BILD_MAX, hoechstens_bytes: BILD_MAX,
/* WIE VIELE BILDER GEHEN — vom Server gesagt, nicht im Browser
gewusst. Dieselbe Überlegung wie bei `hoechstens_bytes` eine
Zeile darüber: Eine zweite Zahl in der Oberfläche wäre die,
die beim Ändern vergessen wird. */
bilder_max: BILDER_MAX,
text_max: TEXT_MAX, text_max: TEXT_MAX,
}); });
} catch (fehler) { } catch (fehler) {
@@ -249,20 +421,45 @@ supportRouter.get("/workspace/api/support", (req, res) => {
} }
}); });
/** Das Bild einer Meldung. /** EIN Bild — egal ob es zur Meldung oder zu einer Antwort gehört.
*
* ==== EINE ROUTE STATT DREI (02.10.2026) ==========================
*
* Hier standen zwei: `/:id/bild` für das Bild der Meldung und
* `/:id/runde/:nr/bild` für das der Antwort. Mit bis zu drei Bildern
* je Stelle hätte jede eine laufende Nummer dazubekommen — zwei
* Routen, die dasselbe tun, und eine dritte Zählung, die zu nichts
* gehört außer zur Reihenfolge in der Anzeige.
*
* Jetzt hat jedes Bild eine eigene Nummer (die Zeile in
* `support_bilder`), und die Adresse nennt sie. Wohin es gehört,
* steht in der Zeile — der Weg muss es nicht noch einmal sagen.
*
* DIE MELDUNGSNUMMER BLEIBT TROTZDEM IM PFAD, obwohl die Bildnummer
* allein eindeutig wäre. Sie ist die SICHTBARKEITSFRAGE: Wer die
* Meldung sehen darf, sieht ihre Bilder. Stünde sie nicht im Pfad,
* müsste die Route sie aus der Bildzeile nachschlagen — dasselbe
* Ergebnis, aber ein Blick in den Quelltext mehr, um es zu wissen.
* Beides muss zusammenpassen, sonst 404.
* *
* Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der * Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der
* ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie, * ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie,
* die dem Dokument jede Ausführung nimmt. */ * die dem Dokument jede Ausführung nimmt.
supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => { *
* 404 UND NICHT 403, wie überall im Haus: Wer eine fremde Meldung
* nicht sehen darf, soll auch nicht erfahren, dass es sie gibt. */
supportRouter.get("/workspace/api/support/:id(\\d+)/bild/:bid(\\d+)", (req, res) => {
try { try {
const m = meldungFuer(req.person, req.params.id); const m = meldungFuer(req.person, req.params.id);
if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" }); if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
const pfad = join(BILD_ORDNER, m.bild_datei); const b = db().prepare("SELECT datei, typ FROM support_bilder WHERE id = ? AND meldung_id = ?")
.get(Number(req.params.bid), m.id);
if (!b) return res.status(404).json({ fehler: "nicht_gefunden" });
const pfad = join(BILD_ORDNER, b.datei);
try { statSync(pfad); } catch { try { statSync(pfad); } catch {
return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." }); return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
} }
res.setHeader("Content-Type", m.bild_typ || "image/png"); res.setHeader("Content-Type", b.typ || "image/png");
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400"); res.setHeader("Cache-Control", "private, max-age=86400");
@@ -308,9 +505,16 @@ function darfMelden(req, res, next) {
* Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */ * Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */
supportRouter.post("/workspace/api/support", supportRouter.post("/workspace/api/support",
darfMelden, darfMelden,
express.raw({ type: "*/*", limit: BILD_MAX }), /* DREIMAL DIE EINZELGRENZE. Das Paket darf so groß sein wie drei
erlaubte Bilder — mehr nicht. Hier `BILD_MAX` stehen zu lassen
hieße: Wer zwei Bildschirmfotos schickt, bekommt eine Absage von
express, bevor irgendeine Zeile dieses Hauses davon erfährt, und
sie lautete „request entity too large" statt eines Satzes, mit
dem jemand etwas anfangen kann. Die Grenze JE BILD prüft
`rumpfZerlegen` weiterhin selbst. */
express.raw({ type: "*/*", limit: BILD_MAX * BILDER_MAX }),
(req, res) => { (req, res) => {
let geschrieben = null; let geschrieben = [];
try { try {
let text = ""; let text = "";
try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; } try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; }
@@ -321,38 +525,33 @@ supportRouter.post("/workspace/api/support",
}); });
} }
let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0; const zerlegt = rumpfZerlegen(req);
if (Buffer.isBuffer(req.body) && req.body.length) { if (zerlegt.fehler) {
const erkannt = dateiErkennen(req.body); return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
/* NUR BILDER. dateiErkennen kennt auch PDF und Ton — hier geht
es um Bildschirmfotos, und alles andere wäre eine Tür, die
niemand bestellt hat. */
if (!erkannt || erkannt.art !== "bild") {
return res.status(415).json({
fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
});
}
mkdirSync(BILD_ORDNER, { recursive: true });
bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
geschrieben = join(BILD_ORDNER, bildDatei);
bildTyp = erkannt.typ;
bildBreite = erkannt.breite || 0;
bildHoehe = erkannt.hoehe || 0;
} }
const abgelegt = bilderAblegen(zerlegt.teile);
if (abgelegt.fehler) {
return res.status(abgelegt.fehler.code).json({ fehler: abgelegt.fehler.text });
}
geschrieben = abgelegt.bilder.map((b) => join(BILD_ORDNER, b.datei));
const n = jetzt(); const n = jetzt();
/* DIE ALTEN BILDSPALTEN WERDEN NICHT MEHR GEFÜLLT (02.10.2026).
Sie stehen noch in der Tabelle, weil sie wegzunehmen hieße,
sie neu zu bauen — und genau dabei gingen am 11.09. drei
Spalten verloren. Ihr Inhalt ist beim Start nach
`support_bilder` gewandert; von dort wird ab jetzt gelesen,
und nur von dort. */
const info = db().prepare(`INSERT INTO support_meldungen const info = db().prepare(`INSERT INTO support_meldungen
(person_id, von_name, von_rolle, text, seite, geraet, (person_id, von_name, von_rolle, text, seite, geraet,
bild_datei, bild_typ, bild_breite, bild_hoehe,
stand, erstellt, geaendert) stand, erstellt, geaendert)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?)`)
.run(req.person.id, req.person.name || null, req.person.rolle || null, .run(req.person.id, req.person.name || null, req.person.rolle || null,
text, sauber(req.get("x-seite"), SEITE_MAX) || null, text, sauber(req.get("x-seite"), SEITE_MAX) || null,
sauber(req.get("user-agent"), GERAET_MAX) || null, sauber(req.get("user-agent"), GERAET_MAX) || null,
bildDatei, bildTyp, bildBreite, bildHoehe,
SUPPORT_STAND.neu, n, n); SUPPORT_STAND.neu, n, n);
const id = Number(info.lastInsertRowid); const id = Number(info.lastInsertRowid);
bilderEintragen(id, null, abgelegt.bilder, n);
protokolliere("support_gemeldet", { protokolliere("support_gemeldet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
@@ -380,10 +579,13 @@ supportRouter.post("/workspace/api/support",
meldung: nachDraussen(frisch, istLeitung(req.person), req.person.id), meldung: nachDraussen(frisch, istLeitung(req.person), req.person.id),
}); });
} catch (fehler) { } catch (fehler) {
/* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst /* DIE DATEIEN GEHEN MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM.
sammelt der Ordner Bilder an, zu denen es keine Meldung gibt — Sonst sammelt der Ordner Bilder an, zu denen es keine Meldung
und niemand merkt es, weil nichts darauf zeigt. */ gibt — und niemand merkt es, weil nichts darauf zeigt.
if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } } ALLE DREI, nicht nur das letzte: Hier stand eine einzelne
Variable, und mit mehreren Bildern wäre genau das die stille
Lücke geworden. */
for (const p of geschrieben) { try { unlinkSync(p); } catch { /* egal */ } }
console.error("[support] Melden:", fehler?.message); console.error("[support] Melden:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" }); res.status(503).json({ fehler: "nicht_verfuegbar" });
} }
@@ -449,8 +651,8 @@ supportRouter.post("/workspace/api/support",
EIN LEERER RUMPF IST ZULÄSSIG. Wer kein Bildschirmfoto hat, soll EIN LEERER RUMPF IST ZULÄSSIG. Wer kein Bildschirmfoto hat, soll
trotzdem antworten können; ein Bild ist Hilfe, keine Hürde. */ trotzdem antworten können; ein Bild ist Hilfe, keine Hürde. */
supportRouter.post("/workspace/api/support/:id/rueckmeldung", supportRouter.post("/workspace/api/support/:id/rueckmeldung",
express.raw({ type: "*/*", limit: BILD_MAX }), (req, res) => { express.raw({ type: "*/*", limit: BILD_MAX * BILDER_MAX }), (req, res) => {
let geschrieben = null; let geschrieben = [];
try { try {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
@@ -482,39 +684,51 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung",
}); });
} }
/* DAS BILD -- dieselbe Erkennung und dieselbe Absage wie beim /* DIE BILDER -- derselbe Weg und dieselbe Absage wie beim
Melden. `dateiErkennen` kennt auch PDF und Ton; hier geht es Melden, und zwar wörtlich dieselben zwei Funktionen. Eine
um Bildschirmfotos, und alles andere wäre eine Tür, nach der eigene Fassung hier wäre die, die beim nächsten Dateityp
niemand gefragt hat. */ vergessen wird. */
let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0; const zerlegt = rumpfZerlegen(req);
if (Buffer.isBuffer(req.body) && req.body.length) { if (zerlegt.fehler) {
const erkannt = dateiErkennen(req.body); return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
if (!erkannt || erkannt.art !== "bild") {
return res.status(415).json({
fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
});
}
mkdirSync(BILD_ORDNER, { recursive: true });
bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
geschrieben = join(BILD_ORDNER, bildDatei);
bildTyp = erkannt.typ;
bildBreite = erkannt.breite || 0;
bildHoehe = erkannt.hoehe || 0;
} }
const abgelegt = bilderAblegen(zerlegt.teile);
if (abgelegt.fehler) {
return res.status(abgelegt.fehler.code).json({ fehler: abgelegt.fehler.text });
}
geschrieben = abgelegt.bilder.map((b) => join(BILD_ORDNER, b.datei));
const n = jetzt(); const n = jetzt();
const runde = m.runde || 1; const runde = m.runde || 1;
db().prepare(`UPDATE support_runden db().prepare(`UPDATE support_runden
SET urteil = ?, urteil_text = ?, urteil_am = ?, SET urteil = ?, urteil_text = ?, urteil_am = ?
urteil_bild_datei = COALESCE(?, urteil_bild_datei),
urteil_bild_typ = COALESCE(?, urteil_bild_typ),
urteil_bild_breite = COALESCE(?, urteil_bild_breite),
urteil_bild_hoehe = COALESCE(?, urteil_bild_hoehe)
WHERE meldung_id = ? AND nr = ?`) WHERE meldung_id = ? AND nr = ?`)
.run(geht ? "geht" : "nicht", wort || null, n, .run(geht ? "geht" : "nicht", wort || null, n, m.id, runde);
bildDatei, bildTyp, bildDatei ? bildBreite : null,
bildDatei ? bildHoehe : null, m.id, runde); /* ==== EINFACH EINTRAGEN -- OHNE „ERSETZEN" (02.10.2026) ======
Hier stand zuerst: alte Bilder dieser Runde löschen, dann die
neuen eintragen. Begründung war „wer nachlegt, ersetzt seine
Antwort, die Bilder müssen mitziehen". Das klang richtig und
WAR ES NICHT -- die Prüfung dazu ist rot geworden, und zwar
zu Recht: Der Fall gibt es nicht.
NACHGEMESSEN: Eine zweite Antwort IN DERSELBEN RUNDE ist
unmöglich. `darfBestaetigen` verlangt den Stand „wartet", und
diese Antwort hier verlässt ihn in jedem Fall -- bei „geht"
nach „erledigt", bei „noch nicht" nach „in Arbeit" UND eine
Runde weiter. Der Melder bekommt beim zweiten Versuch 409;
genau das prüft `pruef-support` an zwei Stellen.
Also wäre das Löschen Code gewesen, der nie läuft -- und
damit Code, den niemand je prüfen kann. Das ist dieselbe
Sorte wie die Prüfung, die immer bestätigt: Sie sieht nach
Sorgfalt aus und trägt nichts.
DIE GRENZE VON DREI STEHT DAMIT AN EINER STELLE: in
`rumpfZerlegen`, je Anfrage -- und eine Anfrage je Runde ist
alles, was es gibt. */
bilderEintragen(m.id, runde, abgelegt.bilder, n);
if (geht) { if (geht) {
db().prepare(`UPDATE support_meldungen db().prepare(`UPDATE support_meldungen
@@ -556,49 +770,23 @@ supportRouter.post("/workspace/api/support/:id/rueckmeldung",
const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id); const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id);
res.json({ meldung: nachDraussen(frisch, false, req.person.id) }); res.json({ meldung: nachDraussen(frisch, false, req.person.id) });
} catch (fehler) { } catch (fehler) {
/* DIE DATEI WIEDER WEG, wenn das Schreiben danach scheitert -- /* DIE DATEIEN WIEDER WEG, wenn das Schreiben danach scheitert --
derselbe Handgriff wie beim Melden. Ohne ihn sammelt der derselbe Handgriff wie beim Melden. Ohne ihn sammelt der
Bildordner Dateien an, zu denen es keine Zeile gibt, und Bildordner Dateien an, zu denen es keine Zeile gibt, und
niemand kann sie je zuordnen. */ niemand kann sie je zuordnen. */
if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } } for (const p of geschrieben) { try { unlinkSync(p); } catch { /* egal */ } }
console.error("[support] Rueckmeldung:", fehler?.message); console.error("[support] Rueckmeldung:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" }); res.status(503).json({ fehler: "nicht_verfuegbar" });
} }
}); });
/* Das Bild zu EINER Runde. /* HIER STAND `/:id/runde/:nr/bild` (02.10.2026 entfernt).
* *
* EIGENE ROUTE UND NICHT `/:id/bild` MIT ZUSATZ: Das Bild der * Sie lieferte das EINE Bild einer Antwort und hätte für drei eine
* Meldung und das einer Antwort sind zwei verschiedene Dinge mit * laufende Nummer dazubekommen müssen. Statt zwei fast gleicher
* verschiedenen Nummern. Eine Route, die mal das eine und mal das * Routen mit je einer Zählung gibt es jetzt eine einzige weiter
* andere liefert, braucht beim Lesen jedes Mal einen Blick in den * oben: `/:id/bild/:bid`. Wohin ein Bild gehört, steht in seiner
* Quelltext. * Zeile — der Weg muss es nicht wiederholen. */
*
* SICHTBARKEIT: dieselbe Regel wie beim Verlauf -- wer die Meldung
* sehen darf, sieht auch ihre Bilder. 404 und nicht 403, damit
* niemand an der Antwort ablesen kann, dass es die Meldung gibt. */
supportRouter.get("/workspace/api/support/:id(\\d+)/runde/:nr(\\d+)/bild", (req, res) => {
try {
const m = meldungFuer(req.person, req.params.id);
if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
const r = db().prepare(`SELECT urteil_bild_datei, urteil_bild_typ
FROM support_runden WHERE meldung_id = ? AND nr = ?`)
.get(m.id, Number(req.params.nr));
if (!r || !r.urteil_bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
const pfad = join(BILD_ORDNER, r.urteil_bild_datei);
try { statSync(pfad); } catch {
return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
}
res.setHeader("Content-Type", r.urteil_bild_typ || "image/png");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400");
liefereDatei(req, res, pfad);
} catch (fehler) {
console.error("[support] Rundenbild:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => { supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => {
try { try {
@@ -743,13 +931,27 @@ supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, re
export function supportAufraeumen() { export function supportAufraeumen() {
try { try {
const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString(); const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString();
const alt = db().prepare(`SELECT id, bild_datei FROM support_meldungen const alt = db().prepare(`SELECT id FROM support_meldungen
WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze); WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze);
if (!alt.length) return 0; if (!alt.length) return 0;
const dazu = db().prepare("SELECT datei FROM support_bilder WHERE meldung_id = ?");
for (const m of alt) { for (const m of alt) {
if (m.bild_datei) { /* ALLE BILDER DER MELDUNG, auch die aus den Antwortrunden
try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ } (02.10.2026). Hier stand `m.bild_datei` — das EINE Bild der
Meldung. Die Bilder zu den Antworten blieben damit auf der
Platte liegen, ohne Zeile, die auf sie zeigt; aufgefallen ist
es beim Umbau auf mehrere Bilder, nicht im Betrieb. Dass die
Liste jetzt aus einer Abfrage kommt statt aus einer Spalte,
ist der Grund, warum sie beim nächsten Ort nicht wieder
unvollständig sein kann. */
for (const b of dazu.all(m.id)) {
try { unlinkSync(join(BILD_ORDNER, b.datei)); } catch { /* schon weg */ }
} }
/* Die Zeilen in `support_bilder` gehen über ON DELETE CASCADE
mit — vorausgesetzt, die Fremdschlüssel sind eingeschaltet.
Deshalb hier zusätzlich von Hand: Eine Aufräumfunktion, die
von einer Einstellung abhängt, räumt irgendwann nicht auf. */
db().prepare("DELETE FROM support_bilder WHERE meldung_id = ?").run(m.id);
db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id); db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id);
} }
console.log(`[support] ${alt.length} erledigte Meldungen aelter als ` console.log(`[support] ${alt.length} erledigte Meldungen aelter als `
+5 -3
View File
@@ -76,6 +76,7 @@ import {
db, sitzungLesen, protokolliere, echteIp, db, sitzungLesen, protokolliere, echteIp,
TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME, istHand, TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME, istHand,
heuteLokal, heuteLokal,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
/* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene /* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene
Importe -- workspace.js braucht beides ebenfalls, und ein Importkreis Importe -- workspace.js braucht beides ebenfalls, und ein Importkreis
@@ -1019,13 +1020,14 @@ treffRouter.post("/workspace/api/treff/mitmachen/:id/uebernehmen", gleicheHerkun
const nun = jetzt(); const nun = jetzt();
const r = db().prepare(`INSERT INTO eintraege const r = db().prepare(`INSERT INTO eintraege
(bereich, art, titel, text, datum, dringlichkeit, status, creator_id, (bereich, art, titel, text, datum, dringlichkeit, status, creator_id,
erstellt, erstellt_von, einsatz) erstellt, erstellt_von, einsatz, haus)
VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?)`) VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?,?)`)
.run( .run(
`${wer?.name ?? "Jemand"} hat sich im Rudel gemeldet`, `${wer?.name ?? "Jemand"} hat sich im Rudel gemeldet`,
`Aus „Mitmachen“: ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(), `Aus „Mitmachen“: ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */ /* Der TAG in Ortszeit (01.10.2026), der Zeitstempel in UTC. */
heuteLokal(), e.erstellt_von, nun, req.person.id, "Das Rudel"); heuteLokal(), e.erstellt_von, nun, req.person.id, "Das Rudel",
hausFuerNeuenEintrag(req.person, "talente"));
protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req), protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req),
detail: `aus Eintrag ${id}` }); detail: `aus Eintrag ${id}` });
res.json({ ok: true, talent_id: Number(r.lastInsertRowid) }); res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
+141 -15
View File
@@ -46,8 +46,9 @@ import { join } from "node:path";
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import { import {
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER,
KANAELE, kanalVonHandle, TREFF_BRETTER, KANAELE, kanalVonHandle, TREFF_BRETTER, brettAufDieserAdresse,
heuteLokal, heuteLokal,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
import { darfSchreiben, sofortFreigeben } from "./workspace-treff.js"; import { darfSchreiben, sofortFreigeben } from "./workspace-treff.js";
import { sichtbar } from "./workspace-bereiche.js"; import { sichtbar } from "./workspace-bereiche.js";
@@ -93,11 +94,36 @@ export function istTikTok(roh) {
} }
/** Mit Frist holen. Ohne sie haengt die Anfrage am fremden Dienst. */ /** Mit Frist holen. Ohne sie haengt die Anfrage am fremden Dienst. */
/** Holt etwas von draussen -- MIT Frist ueber den ganzen Vorgang.
*
* DIE ALTE FASSUNG HAT NUR DEN ANFANG BEWACHT (behoben 05.10.2026).
*
* Sie gab die Antwort zurueck, sobald die Kopfzeilen da waren, und
* raeumte im `finally` die Uhr weg. Was danach kam -- `a.json()` beim
* Abruf der Videodaten, `a.arrayBuffer()` beim Vorschaubild -- lief
* OHNE jede Frist. Bleibt der fremde Server mitten im Koerper
* stehen, wartet unsere Anfrage fuer immer.
*
* Genau das sieht man als "der Knopf bleibt auf `wird geholt ...`
* stehen": keine Fehlermeldung, kein Eintrag, kein Protokolleintrag
* -- weil der Vorgang nie endet. Und es braucht keine Aenderung bei
* uns, um anzufangen: Es haengt am fremden Netz. "Es ging doch immer
* bis jetzt" ist bei so einem Fehler die Regel, nicht die Ausnahme.
*
* Der Koerper wird deshalb GELESEN, solange die Uhr laeuft, und erst
* danach abgeraeumt. 8 Sekunden gelten damit fuer alles zusammen. */
async function holeMitFrist(adresse, kopf = {}) { async function holeMitFrist(adresse, kopf = {}) {
const abbruch = new AbortController(); const abbruch = new AbortController();
const uhr = setTimeout(() => abbruch.abort(), FRIST_MS); const uhr = setTimeout(() => abbruch.abort(), FRIST_MS);
try { try {
return await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" }); const a = await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" });
/* Bei einem Fehlschlag interessiert der Koerper nicht -- aber er
muss trotzdem weg, sonst bleibt die Verbindung offen. */
const roh = Buffer.from(await a.arrayBuffer());
return {
ok: a.ok, status: a.status, headers: a.headers, roh,
text: () => roh.toString("utf8"),
};
} finally { clearTimeout(uhr); } } finally { clearTimeout(uhr); }
} }
@@ -114,13 +140,81 @@ async function holeMitFrist(adresse, kopf = {}) {
const OEMBED_BASIS = process.env.TIKTOK_OEMBED_BASIS || "https://www.tiktok.com/oembed"; const OEMBED_BASIS = process.env.TIKTOK_OEMBED_BASIS || "https://www.tiktok.com/oembed";
/** Die Auskunft zu einem Video: Titel, Cover-Adresse, Account. */ /** Die Auskunft zu einem Video: Titel, Cover-Adresse, Account. */
export async function videoAuskunft(url) { /** Wohin fuehrt ein Kurzlink wirklich -- ohne die Seite zu laden.
*
* `vm.tiktok.com/ZN8kdxP9N/` verraet von aussen nicht, ob dahinter
* ein Video oder ein Fotopost steckt. Nur die Weiterleitung sagt es.
* Gelesen wird deshalb NUR der Ort-Kopf (`redirect: "manual"`); die
* Seite selbst wird nie geholt -- sie ist ein Megabyte gross und
* enthaelt nichts, was wir brauchen. */
async function endgueltigeAdresse(url) {
let jetzt = String(url);
for (let i = 0; i < 5; i++) {
const abbruch = new AbortController();
const uhr = setTimeout(() => abbruch.abort(), FRIST_MS);
let a;
try {
a = await fetch(jetzt, { method: "GET", redirect: "manual", signal: abbruch.signal });
} catch { return jetzt; } finally { clearTimeout(uhr); }
/* Den Koerper wegwerfen, nicht lesen -- sonst bleibt die
Verbindung offen und wir laden die ganze Seite umsonst. */
try { await a.body?.cancel(); } catch { /* egal */ }
const ort = a.headers.get("location");
if (!ort) return jetzt;
try { jetzt = new URL(ort, jetzt).toString(); } catch { return jetzt; }
}
return jetzt;
}
/** Ein Fotopost unter derselben Nummer als Video angefragt.
*
* WARUM DAS NOETIG IST (05.10.2026, gemessen am echten Konto):
*
* .../photo/7693113561568136470 -> 400 "Something went wrong"
* .../video/7693113561568136470 -> 200 mit Titel
*
* TikToks Auskunft kennt Fotoposts (Slideshows) nicht, dieselbe
* Nummer unter /video/ aber schon. Filipe postet seit kurzem solche
* Beitraege -- deshalb "es ging doch immer bis jetzt": Es lag weder
* an einer Auslieferung noch am Haus, sondern an der Art des Posts.
*
* Gibt es nichts zu drehen, kommt `null` zurueck -- dann bleibt es
* bei der ersten Antwort und ihrer Fehlermeldung. */
function alsVideoFassung(url) {
try {
const u = new URL(String(url));
if (!/\/photo\/\d+/.test(u.pathname)) return null;
u.pathname = u.pathname.replace("/photo/", "/video/");
return u.toString();
} catch { return null; }
}
async function auskunftHolen(url) {
const a = await holeMitFrist( const a = await holeMitFrist(
`${OEMBED_BASIS}?url=${encodeURIComponent(url)}`, `${OEMBED_BASIS}?url=${encodeURIComponent(url)}`,
{ accept: "application/json" }); { accept: "application/json" });
if (!a.ok) return { fehler: `TikTok antwortet nicht (${a.status}).` }; if (!a.ok) return { status: a.status, daten: null };
const j = await a.json().catch(() => null); let j = null;
if (!j || !j.title) return { fehler: "TikTok hat nichts zu diesem Video geschickt." }; try { j = JSON.parse(a.text()); } catch { j = null; }
return { status: a.status, daten: j };
}
export async function videoAuskunft(url) {
let versuch = await auskunftHolen(url);
/* ZWEITER ANLAUF FUER FOTOPOSTS. Nur wenn der erste scheitert --
bei einem gewoehnlichen Video kostet das keine einzige zusaetzliche
Anfrage. Erst den Kurzlink aufloesen (er verraet von aussen nicht,
was dahinter steckt), dann /photo/ auf /video/ drehen. */
if (!versuch.daten) {
const echt = await endgueltigeAdresse(url);
const gedreht = alsVideoFassung(echt);
if (gedreht) versuch = await auskunftHolen(gedreht);
}
const j = versuch.daten;
if (!j) return { fehler: `TikTok antwortet nicht (${versuch.status}).` };
if (!j.title) return { fehler: "TikTok hat nichts zu diesem Video geschickt." };
/* Der Account steht in `author_url` (…/@name). `author_name` ist der /* Der Account steht in `author_url` (…/@name). `author_name` ist der
ANZEIGENAME und aendert sich, wenn jemand ihn umstellt -- als ANZEIGENAME und aendert sich, wenn jemand ihn umstellt -- als
Schluessel waere er die falsche Wahl. */ Schluessel waere er die falsche Wahl. */
@@ -140,9 +234,7 @@ async function coverAblegen(adresse) {
let a; let a;
try { a = await holeMitFrist(adresse); } catch { return null; } try { a = await holeMitFrist(adresse); } catch { return null; }
if (!a.ok) return null; if (!a.ok) return null;
const lang = Number(a.headers.get("content-length") || 0); const roh = a.roh;
if (lang > COVER_MAX) return null;
const roh = Buffer.from(await a.arrayBuffer());
if (!roh.length || roh.length > COVER_MAX) return null; if (!roh.length || roh.length > COVER_MAX) return null;
/* DIESELBE BYTEPRUEFUNG WIE BEI HOCHGELADENEN DATEIEN. Was von /* DIESELBE BYTEPRUEFUNG WIE BEI HOCHGELADENEN DATEIEN. Was von
draussen kommt, ist von draussen -- egal ob jemand es hochlaedt draussen kommt, ist von draussen -- egal ob jemand es hochlaedt
@@ -161,7 +253,14 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
let abgelegt = null; let abgelegt = null;
try { try {
const bereich = String(req.params.bereich); const bereich = String(req.params.bereich);
if (!TREFF_BRETTER.includes(bereich)) { /* DIESE ROUTE HAT IHR EIGENES TOR. Sie haengt nicht an der
Middleware von workspace-bereiche.js -- deshalb steht der
Adressriegel hier eigens. Ohne ihn liesse sich von der
Agenturadresse aus weiterhin ein Video anlegen, das danach
niemand dort sieht: genau der Zustand, der heute gemeldet
wurde, nur still. */
if (!TREFF_BRETTER.includes(bereich)
|| !brettAufDieserAdresse(req.person, bereich)) {
return res.status(404).json({ fehler: "nicht_gefunden" }); return res.status(404).json({ fehler: "nicht_gefunden" });
} }
const warum = darfSchreiben(req.person, bereich); const warum = darfSchreiben(req.person, bereich);
@@ -169,6 +268,10 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
const url = String(req.body?.url ?? "").trim(); const url = String(req.body?.url ?? "").trim();
if (!istTikTok(url)) { if (!istTikTok(url)) {
protokolliere("video_gescheitert", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${bereich} ${String(url).slice(0, 120)} -- keine TikTok-Adresse`,
});
return res.status(400).json({ fehler: "Das ist keine TikTok-Adresse." }); return res.status(400).json({ fehler: "Das ist keine TikTok-Adresse." });
} }
@@ -226,7 +329,20 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
} }
const auskunft = await videoAuskunft(url); const auskunft = await videoAuskunft(url);
if (auskunft.fehler) return res.status(502).json({ fehler: auskunft.fehler }); if (auskunft.fehler) {
/* NICHT STILL SCHEITERN. Bis heute stand im Protokoll nur der
Erfolg (`video_eingelesen`). Blieb TikTok eine Antwort
schuldig, gab es hinterher nichts zum Nachsehen -- und auf
die Frage "warum kam mein Video nicht an?" liess sich nicht
einmal sagen, OB der Versuch den Server erreicht hat. Genau
diese Reihenfolge hat am 06.09. im Shop zwei Stunden
gekostet, und heute noch einmal einen halben Tag. */
protokolliere("video_gescheitert", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${bereich} ${url} -- ${auskunft.fehler}`.slice(0, 180),
});
return res.status(502).json({ fehler: auskunft.fehler });
}
/* NUR SEINE EIGENEN KANAELE. Ein fremdes Video in Filipes /* NUR SEINE EIGENEN KANAELE. Ein fremdes Video in Filipes
Highlights waere nicht bloss falsch einsortiert -- es waere ein Highlights waere nicht bloss falsch einsortiert -- es waere ein
@@ -281,13 +397,14 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
const { lastInsertRowid } = db().prepare(` const { lastInsertRowid } = db().prepare(`
INSERT INTO eintraege INSERT INTO eintraege
(bereich, art, titel, datum, status, erstellt, erstellt_von, (bereich, art, titel, datum, status, erstellt, erstellt_von,
quelle_url, quelle_kanal, quelle_geholt_am, aus_eintrag_id) quelle_url, quelle_kanal, quelle_geholt_am, aus_eintrag_id, haus)
VALUES (?,?,?,?, 'offen', ?,?,?,?,?,?)`) VALUES (?,?,?,?, 'offen', ?,?,?,?,?,?,?)`)
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel daneben in /* Der TAG in Ortszeit (01.10.2026), der Zeitstempel daneben in
UTC -- ein Zeitpunkt ist ueberall derselbe, ein Kalendertag UTC -- ein Zeitpunkt ist ueberall derselbe, ein Kalendertag
nicht. */ nicht. */
.run(bereich, arten[bereich] || "clip", auskunft.titel, heuteLokal(), .run(bereich, arten[bereich] || "clip", auskunft.titel, heuteLokal(),
jetzt, req.person.id, url, ablage, jetzt, ausQuelle ? aus : null); jetzt, req.person.id, url, ablage, jetzt, ausQuelle ? aus : null,
hausFuerNeuenEintrag(req.person, bereich));
eintragId = Number(lastInsertRowid); eintragId = Number(lastInsertRowid);
} }
@@ -335,6 +452,14 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
Datei ohne Eintrag findet niemand wieder und niemand loescht sie Datei ohne Eintrag findet niemand wieder und niemand loescht sie
-- sie liegt einfach fuer immer da. */ -- sie liegt einfach fuer immer da. */
if (abgelegt) { try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* egal */ } } if (abgelegt) { try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* egal */ } }
/* Auch der Absturz gehoert ins Protokoll -- er ist der Fall, bei
dem sonst GAR nichts dasteht. */
try {
protokolliere("video_gescheitert", {
personId: req.person?.id, rolle: req.person?.rolle, ip: echteIp(req),
detail: `Abbruch: ${String(fehler?.message || fehler)}`.slice(0, 180),
});
} catch { /* das Protokoll darf die Antwort nicht aufhalten */ }
console.error("[workspace] Video einlesen:", fehler?.message); console.error("[workspace] Video einlesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" }); res.status(503).json({ fehler: "nicht_verfuegbar" });
} }
@@ -420,7 +545,8 @@ export async function videoNochDa(url) {
return { da: false, grund: `TikTok kennt es nicht (${a.status})` }; return { da: false, grund: `TikTok kennt es nicht (${a.status})` };
} }
if (!a.ok) return { da: null, grund: `unerwartet (${a.status})` }; if (!a.ok) return { da: null, grund: `unerwartet (${a.status})` };
const j = await a.json().catch(() => null); let j = null;
try { j = JSON.parse(a.text()); } catch { j = null; }
/* Antwort ohne Titel: TikTok liefert das auch fuer entfernte Videos /* Antwort ohne Titel: TikTok liefert das auch fuer entfernte Videos
mit Status 200. Ein leerer Titel ist deshalb eine Aussage. */ mit Status 200. Ein leerer Titel ist deshalb eine Aussage. */
if (!j || !j.title) return { da: false, grund: "Antwort ohne Titel" }; if (!j || !j.title) return { da: false, grund: "Antwort ohne Titel" };
+201 -21
View File
@@ -56,6 +56,7 @@ import {
KATALOG_EMPFAENGER, KATALOG_EMPFAENGER,
KATALOG_EMPFAENGER_SQL, KATALOG_EMPFAENGER_SQL,
heuteLokal, heuteLokal,
hausFuerNeuenEintrag,
} from "./workspace.js"; } from "./workspace.js";
/* Dieselbe Meldung wie beim anderen Bewerbungsweg -- eine Stelle, /* Dieselbe Meldung wie beim anderen Bewerbungsweg -- eine Stelle,
@@ -1554,17 +1555,65 @@ function gleicheHerkunft(req, res, next) {
* Leitung sie zutraegt -- dann steht bei ihr Annehmen/Ablehnen, * Leitung sie zutraegt -- dann steht bei ihr Annehmen/Ablehnen,
* und das ist richtig so: Sie hat noch nicht ja gesagt. * und das ist richtig so: Sie hat noch nicht ja gesagt.
*/ */
function katalogAufgabeAnlegen(v, zielId, vonId, zustand = "offen") { /* ==== WAS BEIM UEBERNEHMEN ANDERS SEIN DARF (02.10.2026) ============
VanVan im Support, Meldung #6, zweite Haelfte: „Bei den Vorlagen
laesst sich weder die Frist anpassen noch ‚dauerhaft' einstellen,
und eine Anmerkung fehlt auch."
DREI WUENSCHE, EINE STELLE. Sie reisen als `wunsch`-Objekt durch
beide Zweige, die aus einer Vorlage eine Aufgabe machen -- der
Katalog hier und die Creator-Vorlagen weiter unten. Drei einzelne
Parameter waeren an der naechsten Aufrufstelle drei Gelegenheiten,
einen zu vergessen.
NICHTS IST PFLICHT. Ohne Angabe bleibt alles, wie es war: Frist
aus `tage`, nicht dauerhaft, keine Notiz. Der schnelle Weg
(„An alle", zwoelf Aufgaben mit einem Druck) darf durch diesen
Umbau nicht laenger werden.
DIE ANMERKUNG WIRD EINE NOTIZ, kein Anhaengsel an der
Beschreibung. `aufgaben_notizen` gibt es seit Langem, die Aufgabe
zeigt sie ohnehin an, und sie traegt, von wem sie stammt. In die
Beschreibung geschrieben waere sie nicht mehr von der Vorlage zu
unterscheiden -- und beim naechsten Mal haette dieselbe Vorlage
einen anderen Text. */
function fristAusWunsch(v, wunsch) {
/* EINE DAUERHAFTE AUFGABE HAT KEINE FRIST. Sie waere ab dem
naechsten Tag fuer immer ueberfaellig -- und eine Warnung, die
immer kommt, ist keine mehr. Dieselbe Regel steht seit Langem im
Aenderungsweg (workspace-aufgaben.js, `if (aus.dauerhaft)
aus.frist = null`); haette sie hier gefehlt, gaebe es zwei
Antworten auf dieselbe Frage, und die hiesige waere die falsche. */
if (wunsch?.dauerhaft) return null;
if (wunsch?.frist) return wunsch.frist;
/* Die Frist wird aus `tage` gerechnet, in ORTSZEIT. Ueber
toISOString kaeme zwischen Mitternacht und zwei Uhr der Vortag
heraus -- derselbe Fehler, der im Haus schon einmal eine
Pruefung falsch rot gemacht hat. */
const d = new Date(Date.now() + (v.tage || 7) * 86400_000); const d = new Date(Date.now() + (v.tage || 7) * 86400_000);
const frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`; + `-${String(d.getDate()).padStart(2, "0")}`;
}
/** Die Anmerkung als erste Notiz an der frischen Aufgabe. */
function anmerkungAnlegen(aufgabeId, wunsch, vonId) {
if (!wunsch?.anmerkung) return;
db().prepare(`INSERT INTO aufgaben_notizen (aufgabe_id, person_id, text, erstellt)
VALUES (?,?,?,?)`).run(aufgabeId, vonId ?? null, wunsch.anmerkung, jetzt());
}
function katalogAufgabeAnlegen(v, zielId, vonId, zustand = "offen", wunsch = null) {
const frist = fristAusWunsch(v, wunsch);
db().prepare(`INSERT INTO aufgaben db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, verantwortlich_id, frist, (titel, beschreibung, status, prioritaet, verantwortlich_id, frist,
kategorie, vorlage, erstellt, erstellt_von) kategorie, vorlage, dauerhaft, erstellt, erstellt_von)
VALUES (?,?,'offen',?,?,?,?,?,?,?)`) VALUES (?,?,'offen',?,?,?,?,?,?,?,?)`)
.run(v.titel, v.text, v.tage <= 1 ? "hoch" : "mittel", .run(v.titel, v.text, v.tage <= 1 ? "hoch" : "mittel",
zielId, frist, v.kategorie, v.schluessel, jetzt(), vonId); zielId, frist, v.kategorie, v.schluessel, wunsch?.dauerhaft ? 1 : 0,
jetzt(), vonId);
const id = db().prepare("SELECT last_insert_rowid() AS id").get().id; const id = db().prepare("SELECT last_insert_rowid() AS id").get().id;
anmerkungAnlegen(id, wunsch, vonId);
const nun = jetzt(); const nun = jetzt();
db().prepare(`INSERT INTO aufgaben_zuteilung db().prepare(`INSERT INTO aufgaben_zuteilung
(aufgabe_id, person_id, zustand, zugeteilt_am, zugeteilt_von, geantwortet_am) (aufgabe_id, person_id, zustand, zugeteilt_am, zugeteilt_von, geantwortet_am)
@@ -1580,6 +1629,12 @@ function katalogAufgabeAnlegen(v, zielId, vonId, zustand = "offen") {
drueben bei den Aufgaben -- ein Satz, kein Aufsatz. */ drueben bei den Aufgaben -- ein Satz, kein Aufsatz. */
const BEWERBUNG_TEXT_MAX = 600; const BEWERBUNG_TEXT_MAX = 600;
/* Wie lang darf die Anmerkung beim Uebernehmen sein? Dieselbe Zahl wie
`NOTIZ_MAX` in workspace-aufgaben.js -- sie WIRD eine Notiz, und
zwei verschiedene Grenzen fuer dieselbe Zeile waeren die Sorte
Unterschied, die man erst bemerkt, wenn etwas abgeschnitten ist. */
const ANMERKUNG_MAX = 2000;
/* ===================================================================== /* =====================================================================
WAS NACH EINER ANTWORT UEBRIG BLEIBT (30.09.2026) WAS NACH EINER ANTWORT UEBRIG BLEIBT (30.09.2026)
@@ -1624,7 +1679,7 @@ function vorlagenAntwortenFuer(person) {
try { try {
return db().prepare(` return db().prepare(`
SELECT b.id, b.vorlage, b.zustand, b.text, b.entscheid_text, SELECT b.id, b.vorlage, b.zustand, b.text, b.entscheid_text,
b.entschieden_am, b.aufgabe_id, e.name AS von_name b.entschieden_am, b.aufgabe_id, b.gesehen_am, e.name AS von_name
FROM vorlagen_bewerbungen b FROM vorlagen_bewerbungen b
LEFT JOIN personen e ON e.id = b.entschieden_von LEFT JOIN personen e ON e.id = b.entschieden_von
WHERE b.person_id = ? WHERE b.person_id = ?
@@ -1632,7 +1687,16 @@ function vorlagenAntwortenFuer(person) {
AND COALESCE(b.entschieden_am, '') >= ? AND COALESCE(b.entschieden_am, '') >= ?
ORDER BY b.entschieden_am DESC`) ORDER BY b.entschieden_am DESC`)
.all(person.id, vorTagen(ANTWORT_TAGE)) .all(person.id, vorTagen(ANTWORT_TAGE))
.map((z) => ({ ...z, titel: vorlageZuSchluessel(z.vorlage)?.titel || z.vorlage })); /* `gesehen` ALS JA/NEIN und nicht der Zeitstempel: Die
Oberflaeche fragt „stand das schon offen da?", nicht „wann".
Ein Datum dorthin zu schicken hiesse, dass sie es
vergleichen muss -- und beim naechsten Mal anders. */
.map((z) => ({
...z,
gesehen: !!z.gesehen_am,
gesehen_am: undefined,
titel: vorlageZuSchluessel(z.vorlage)?.titel || z.vorlage,
}));
} catch (fehler) { } catch (fehler) {
console.error("[vorlagen] Antworten:", fehler?.message); console.error("[vorlagen] Antworten:", fehler?.message);
return []; return [];
@@ -1925,6 +1989,60 @@ vorlagenRouter.post("/workspace/api/vorlagen/bewerbung-zurueck", gleicheHerkunft
} }
}); });
/* ---- „Verstanden" -- die Antwort zur Kenntnis nehmen -----------------
VanVan im Support, Meldung #15: „Die Modis können die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unübersichtlich."
NUR DIE EIGENEN, und nur die BEANTWORTETEN. Eine Bewerbung, auf die
noch niemand geantwortet hat, kann man nicht gelesen haben -- der
Versuch bekommt deshalb 404 und nicht etwa ein stilles „ok".
EINMAL IST EINMAL: Ein zweiter Druck aendert nichts. `gesehen_am`
wird nur gesetzt, wo es noch leer ist -- sonst wanderte die Zeile
bei jedem Klick ans Ende der Liste und man saehe nicht mehr, wann
man sie wirklich gelesen hat.
`alle: true` NIMMT ALLE AUF EINMAL. Wer nach dem Urlaub sieben
Antworten vorfindet, soll nicht siebenmal tippen muessen -- und
sieben einzelne Anfragen waeren sieben Gelegenheiten, dass eine
davon unterwegs verlorengeht. */
vorlagenRouter.post("/workspace/api/vorlagen/bewerbung/gesehen", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const nun = jetzt();
if (req.body?.alle === true) {
const n = db().prepare(`UPDATE vorlagen_bewerbungen
SET gesehen_am = ?
WHERE person_id = ? AND gesehen_am IS NULL
AND zustand IN ('angenommen','abgelehnt')`)
.run(nun, req.person.id).changes;
return res.json({ ok: true, wieviele: n,
antworten: vorlagenAntwortenFuer(req.person) });
}
const id = Number(req.body?.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const b = db().prepare("SELECT * FROM vorlagen_bewerbungen WHERE id = ?").get(id);
/* NICHT MEINE = GIBT ES NICHT -- dieselbe Antwort wie beim
Zurueckziehen eine Route weiter oben. Ein 403 verriete, dass
es sie gibt und wem sie gehoert. */
if (!b || b.person_id !== req.person.id
|| !["angenommen", "abgelehnt"].includes(b.zustand)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!b.gesehen_am) {
db().prepare("UPDATE vorlagen_bewerbungen SET gesehen_am = ? WHERE id = ?")
.run(nun, id);
}
res.json({ ok: true, wieviele: b.gesehen_am ? 0 : 1,
antworten: vorlagenAntwortenFuer(req.person) });
} catch (f) {
console.error("[vorlagen] bewerbung/gesehen:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Entscheiden: annehmen oder ablehnen ------------------------------ /* ---- Entscheiden: annehmen oder ablehnen ------------------------------
EINE SCHLEIFE FUER BEIDE WEGE. Zwei fast gleiche Routen waeren zwei EINE SCHLEIFE FUER BEIDE WEGE. Zwei fast gleiche Routen waeren zwei
@@ -2017,6 +2135,66 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
try { try {
const art = String(req.body.art || ""); const art = String(req.body.art || "");
const nr = Number(req.body.nr); const nr = Number(req.body.nr);
/* ==== DIE DREI WUENSCHE AUS DEM FENSTER (02.10.2026) ========
VanVan, Meldung #6: „Bei den Vorlagen laesst sich weder die
Frist anpassen noch ‚dauerhaft' einstellen, und eine
Anmerkung fehlt auch."
GEPRUEFT WIRD HIER, nicht im Browser. Das Fenster bietet ein
Datumsfeld an; was ankommt, ist trotzdem eine Zeichenkette
aus dem Netz. Ein `frist` wie „morgen" oder „2026-13-45"
stuende sonst in der Datenbank und waere beim Sortieren
stiller Unsinn.
NICHT IN DIE VERGANGENHEIT. Eine Frist, die schon vorbei
ist, macht die Aufgabe in dem Moment rot, in dem sie
entsteht -- und „ueberfaellig" verliert seine Bedeutung,
wenn es auch frisch Verteiltes trifft.
„DAUERHAFT" DARF NUR, WER VERTEILEN DARF. Eine dauerhafte
Aufgabe laesst sich nicht abhaken (siehe den Commit vom
02.10. frueh) -- wer sie sich selbst anlegen koennte, haette
etwas, das er nie wieder loswird. Die Entscheidung gehoert
zur Leitung, genau wie das Beenden.
DIE PRUEFUNG STEHT VOR ALLEN ZWEIGEN, weil beide, die
Aufgaben anlegen, sie brauchen -- zweimal dasselbe waere
zweimal zu pflegen. */
const wunsch = (() => {
const roh = req.body || {};
const w = { frist: null, dauerhaft: false, anmerkung: null };
if (typeof roh.frist === "string" && roh.frist) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(roh.frist)) return { fehler: "frist_ungueltig" };
const d = new Date(roh.frist + "T12:00:00");
if (Number.isNaN(d.getTime())
|| d.toISOString().slice(0, 10) !== roh.frist) {
return { fehler: "frist_ungueltig" };
}
if (roh.frist < heuteLokal()) return { fehler: "frist_vergangen" };
w.frist = roh.frist;
}
if (roh.dauerhaft === true) {
if (!darfAufgabenVerteilen(req.person)) return { fehler: "dauerhaft_nicht_erlaubt" };
w.dauerhaft = true;
}
if (typeof roh.anmerkung === "string" && roh.anmerkung.trim()) {
w.anmerkung = roh.anmerkung.replace(/\r\n/g, "\n").trim().slice(0, ANMERKUNG_MAX);
}
return w;
})();
if (wunsch.fehler) {
const saetze = {
frist_ungueltig: "Diese Frist versteht der Server nicht – bitte ein Datum wählen.",
frist_vergangen: "Diese Frist liegt in der Vergangenheit.",
dauerhaft_nicht_erlaubt:
"„Dauerhaft“ dürfen nur DogFather und die rechte Hand setzen – "
+ "eine dauerhafte Aufgabe lässt sich nicht abhaken.",
};
return res.status(wunsch.fehler === "dauerhaft_nicht_erlaubt" ? 403 : 400)
.json({ fehler: saetze[wunsch.fehler] });
}
/* Für wen? Ein Creator immer für sich; Betreuer für den Creator, /* Für wen? Ein Creator immer für sich; Betreuer für den Creator,
den sie gerade ansehen. */ den sie gerade ansehen. */
let creatorId = req.person.rolle === "creator" let creatorId = req.person.rolle === "creator"
@@ -2035,14 +2213,14 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
const einfuegen = (bereich, unterart, titel, text, zusatz = {}) => { const einfuegen = (bereich, unterart, titel, text, zusatz = {}) => {
db().prepare(`INSERT INTO eintraege db().prepare(`INSERT INTO eintraege
(bereich, art, titel, text, datum, status, creator_id, hook, format, (bereich, art, titel, text, datum, status, creator_id, hook, format,
erstellt, erstellt_von) erstellt, erstellt_von, haus)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel weiter /* Der TAG in Ortszeit (01.10.2026), der Zeitstempel weiter
unten in UTC. Aus dem Startkatalog angelegte Eintraege unten in UTC. Aus dem Startkatalog angelegte Eintraege
trugen sonst nachts das Datum von gestern. */ trugen sonst nachts das Datum von gestern. */
.run(bereich, unterart, titel, text, heuteLokal(), "offen", .run(bereich, unterart, titel, text, heuteLokal(), "offen",
creatorId, zusatz.hook || null, zusatz.format || null, creatorId, zusatz.hook || null, zusatz.format || null,
jetzt(), req.person.id); jetzt(), req.person.id, hausFuerNeuenEintrag(req.person, bereich));
angelegt.push(titel); angelegt.push(titel);
}; };
@@ -2095,22 +2273,24 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
const auswahl = art === "aufgaben_alle" ? feld : [feld[nr]]; const auswahl = art === "aufgaben_alle" ? feld : [feld[nr]];
if (auswahl.some((v) => !v)) return res.status(400).json({ fehler: "Unbekannte Vorlage." }); if (auswahl.some((v) => !v)) return res.status(400).json({ fehler: "Unbekannte Vorlage." });
for (const v of auswahl) { for (const v of auswahl) {
/* Die Frist wird aus `tage` gerechnet, in ORTSZEIT. Ueber /* Frist, „dauerhaft" und die Anmerkung kommen aus demselben
toISOString kaeme zwischen Mitternacht und zwei Uhr der `wunsch` wie im Katalogzweig -- und aus derselben
Vortag heraus -- derselbe Fehler, der im Haus schon einmal Funktion. Zwei Rechnungen fuer dieselbe Frist waeren die
eine Pruefung falsch rot gemacht hat. */ zweite, die beim naechsten Mal nicht mitgeaendert wird. */
const d = new Date(Date.now() + (v.tage || 7) * 86400_000); const frist = fristAusWunsch(v, wunsch);
const frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
+ `-${String(d.getDate()).padStart(2, "0")}`;
/* Die Kennung der Vorlage wandert mit (09.09.2026). Nur so /* Die Kennung der Vorlage wandert mit (09.09.2026). Nur so
kann das Brett spaeter zeigen, was schon uebernommen ist -- kann das Brett spaeter zeigen, was schon uebernommen ist --
und der Titel taugt dafuer nicht: Er darf geaendert werden. */ und der Titel taugt dafuer nicht: Er darf geaendert werden. */
db().prepare(`INSERT INTO aufgaben db().prepare(`INSERT INTO aufgaben
(titel, beschreibung, status, prioritaet, creator_id, frist, (titel, beschreibung, status, prioritaet, creator_id, frist,
vorlage, erstellt, erstellt_von) vorlage, dauerhaft, erstellt, erstellt_von)
VALUES (?,?,'offen',?,?,?,?,?,?)`) VALUES (?,?,'offen',?,?,?,?,?,?,?)`)
.run(v.titel, v.text, stufe === "anfaenger" ? "hoch" : "mittel", .run(v.titel, v.text, stufe === "anfaenger" ? "hoch" : "mittel",
creatorId, frist, v.schluessel, jetzt(), req.person.id); creatorId, frist, v.schluessel, wunsch.dauerhaft ? 1 : 0,
jetzt(), req.person.id);
anmerkungAnlegen(
db().prepare("SELECT last_insert_rowid() AS id").get().id,
wunsch, req.person.id);
angelegt.push(v.titel); angelegt.push(v.titel);
} }
@@ -2298,7 +2478,7 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
: new Set(); : new Set();
for (const v of auswahl) { for (const v of auswahl) {
if (liegtSchon.has(v.schluessel)) { lagSchonDa.push(v.titel); continue; } if (liegtSchon.has(v.schluessel)) { lagSchonDa.push(v.titel); continue; }
katalogAufgabeAnlegen(v, ziel.id, req.person.id); katalogAufgabeAnlegen(v, ziel.id, req.person.id, "offen", wunsch);
angelegt.push(v.titel); angelegt.push(v.titel);
} }
} }
+25 -48
View File
@@ -760,61 +760,38 @@ for (const [weg, entscheidung] of [["annehmen", "ja"], ["ablehnen", "nein"]]) {
}); });
} }
/* ---- Meinen eigenen Stand setzen ------------------------------------ */ /* ==== HIER STAND `/mein-stand` (03.10.2026 entfernt) ==============
zuteilungRouter.post("/workspace/api/aufgaben/:id/mein-stand", (req, res) => {
try {
const a = holen(req, res); if (!a) return;
const z = meine(a.id, req.person.id);
if (!z) return res.status(403).json({ fehler: "nicht_zugeteilt" });
const ziel = String(req.body?.zustand ?? "").trim(); VanVan im Support, Meldung #8: „Wenn man auf ich fange an drückt
/* NUR DIE DREI, DIE MAN SELBST SETZEN DARF. "offen" zurückzudrehen steht dort in Bearbeitung und wenn man auf fertig drückt dann wird
hieße, die eigene Zusage zu löschen; "abgelehnt" hat einen es zu erledigt. DIE AUFGABE BLEIBT ABER IM STATUS OFFEN STEHEN."
eigenen Weg, weil es eine Begründung braucht. */
if (!["angenommen", "arbeit", "erledigt"].includes(ziel)) {
return res.status(400).json({ fehler: "ungueltiger_zustand" });
}
if (z.zustand === "abgelehnt") return res.status(409).json({ fehler: "abgelehnt" });
/* ==== EINE DAUERHAFTE AUFGABE WIRD NICHT FERTIG (30.09.2026) ==== Gestern habe ich die zwei Knöpfe entfernt, die diese Route riefen,
und daneben geschrieben: „Der Weg bleibt bestehen — er ist die
Schranke, falls ihn jemand direkt anspricht."
VanVan im Support: „… dass die Aufgabe dauerhaft sein soll und DAS WAR EINE AUSREDE, KEINE BEGRÜNDUNG. Eine Route ist keine
somit nicht vom Modi in den Status erledigt gesetzt werden Schranke gegen sich selbst. Sie setzte weiterhin NUR
kann." `aufgaben_zuteilung.zustand` und ließ `aufgaben.status` stehen —
also genau den Zustand, den VanVan gemeldet hat. Unsichtbar
gemacht, nicht abgeschafft.
Sie ist keine Aufgabe, die man abarbeitet, sondern eine, die NACHGEMESSEN, BEVOR SIE WEGKAM: Kein einziger Aufruf mehr im
man TUT. „Neue begruessen" ist nicht fertig, wenn man es einmal ausgelieferten Browsercode (`grep` über alle JS- und HTML-Dateien
gemacht hat. des Workspace). Nur zwei Prüfungen benutzten sie — und eine davon
nickte den Fehler sogar ab: Sie setzte „in Bearbeitung" und prüfte
HTTP 200, nie den Aufgabenstatus daneben.
„ICH FANGE AN" BLEIBT ERLAUBT. Auch eine stehende Aufgabe hat WAS STATTDESSEN GILT: `PATCH /workspace/api/aufgaben/:id` mit
einen Anfang, und der Unterschied zwischen „angenommen" und „in `{ status }`. Er setzt den Status UND zieht die Zuteilung mit
Arbeit" sagt etwas. Gesperrt ist nur das Abhaken. (`zuteilungenNachStatus`), kennt dieselbe Sperre für dauerhafte
Aufgaben und dieselbe Rechteprüfung. Eine Frage, eine Antwort.
DIE SPERRE STEHT HIER UND NICHT NUR IM BROWSER. Ein fehlender ENTFERNT STATT AUSKOMMENTIERT — dieselbe Entscheidung wie bei
Knopf ist eine Bitte; abgelehnt wird am Server -- wer die `/vorlagen/hilfe` am 01.09.: Eine Route, die niemand mehr aufruft,
Schnittstelle direkt anspricht, kaeme sonst durch. wird beim nächsten Mal für lebenden Code gehalten und mitgepflegt.
================================================================== */
BEENDET WIRD SIE VON DER LEITUNG, ueber den Status der Aufgabe
selbst. Eine Pflicht, die niemand mehr beenden kann, waere eine
Falle statt einer Regel. */
if (ziel === "erledigt" && a.dauerhaft) {
return res.status(409).json({ fehler: "dauerhafte_aufgabe" });
}
db().prepare(`UPDATE aufgaben_zuteilung
SET zustand = ?, erledigt_am = ?
WHERE id = ?`).run(ziel, ziel === "erledigt" ? jetzt() : null, z.id);
protokolliere("aufgabe_stand", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${a.id} ${ziel}`.slice(0, 120),
});
res.json({ ok: true, zustand: ziel });
} catch (f) {
console.error("[zuteilung] mein-stand:", f?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---- Bewerten (Abschnitt 5) ----------------------------------------- */ /* ---- Bewerten (Abschnitt 5) ----------------------------------------- */
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerten", (req, res) => { zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerten", (req, res) => {
+303
View File
@@ -145,6 +145,25 @@ export const WIE_RECHTE_HAND = new Set(["hand", "linke"]);
/** Hat diese Person die Team-Rechte einer Hand (rechts oder links)? */ /** Hat diese Person die Team-Rechte einer Hand (rechts oder links)? */
export const istHand = (person) => !!person && WIE_RECHTE_HAND.has(person.rolle); export const istHand = (person) => !!person && WIE_RECHTE_HAND.has(person.rolle);
/** Genau die RECHTE Hand -- nicht die linke.
*
* WARUM ES BEIDES BRAUCHT (03.10.2026). `istHand` fasst beide Haende
* zusammen, und das ist fuer die meisten Fragen richtig. Fuer manche
* nicht: Die linke Hand hat im Haus durchgaengig das kleinere Recht
* (sie darf keine Aufgaben annehmen, nicht in den vertraulichen
* Meldeweg). Wer dort `istHand` nimmt, gibt ihr versehentlich ein
* Recht, das niemand ihr geben wollte -- und sieht es nicht, weil
* der Name so harmlos aussieht.
*
* Der Vergleich `rolle === "hand"` stand dafuer bisher VIERMAL
* verstreut im Haus (workspace-chat zweimal, workspace-material,
* workspace-rechte). Vier Abschriften derselben Regel sind vier
* Gelegenheiten, dass eine nachgezogen wird und die anderen nicht.
* Neue Stellen nehmen deshalb diese hier; die vier alten sind
* unveraendert und warten auf eine eigene, gemessene Umstellung --
* nebenbei umgebaut waeren sie vier ungepruefte Rechteaenderungen. */
export const istRechteHand = (person) => !!person && person.rolle === "hand";
/* ===== WER FUEHRT DIE ZUGAENGE? (25.09.2026) ======================== /* ===== WER FUEHRT DIE ZUGAENGE? (25.09.2026) ========================
Filipe, zum wiederholten Mal: „die rechte hand sieht das immer noch Filipe, zum wiederholten Mal: „die rechte hand sieht das immer noch
@@ -2141,6 +2160,53 @@ const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TR
assets/js/bereiche.js und wird dort ergaenzt. `[]` heisst "diese assets/js/bereiche.js und wird dort ergaenzt. `[]` heisst "diese
Rolle bekommt gar nichts"; ihr eine einzelne Kachel zu geben waere Rolle bekommt gar nichts"; ihr eine einzelne Kachel zu geben waere
die Ausnahme, die spaeter niemand mehr erklaeren kann. */ die Ausnahme, die spaeter niemand mehr erklaeren kann. */
/** Gibt es dieses Brett auf der Adresse, auf der jemand angemeldet ist?
*
* DIE SIEBEN BRETTER DES RUDELS GIBT ES NUR BEI TEAM DOGI (05.10.2026).
*
* Filipe: "ich kann gerade bei highlights keine videos hochladen."
*
* Nachgemessen an der laufenden Datenbank: 49 Highlights, ALLE mit
* `haus = crew` -- so legt `hausFuerNeuenEintrag` sie an, ausdruecklich
* "egal, von welcher Adresse aus jemand hineinschreibt". Seit dem
* Adressriegel vom 03.10. zeigt die Agenturadresse aber nur Eintraege
* mit `haus = agentur`. Ergebnis:
*
* crew. -> 49 Highlights
* workspace-> 0
*
* Das Brett stand dort also leer da, und was jemand einfuegte,
* verschwand in derselben Sekunde -- angelegt, aber sofort
* ausgeblendet. Von aussen sieht das aus wie "das Hochladen geht
* nicht"; in Wahrheit ging alles, nur sehen konnte er es nicht.
*
* Zwei Regeln widersprachen sich: Die eine sagt "diese Bretter sind
* immer crew", die andere "auf dieser Adresse nur agentur". Aufgeloest
* wird es in Filipes Richtung (05.10.2026, nach Rueckfrage): Auf der
* Agenturadresse gibt es diese Bretter gar nicht mehr -- weder als
* Kachel noch als Seite noch als Schnittstelle. Das ist die Regel vom
* 24.09. beim Wort genommen: "auf jeder Adresse nur deren Bestand."
*
* OHNE ADRESSE (Pruefungen, 127.0.0.1) bleibt alles offen. Dieselbe
* vorsichtige Richtung wie in `nurDiesesHaus` -- ein Riegel, der auch
* dort zuschlaegt, wo niemand ein Haus kennt, sperrt die eigenen
* Messungen aus. */
export const brettAufDieserAdresse = (person, bereich) =>
!(TREFF_BRETTER.includes(String(bereich)) && person?.haus === "agentur");
/* HIER STAND EIN KACHELFILTER -- und er war toter Code (05.10.2026).
Die Idee war, die Rudel-Kacheln auf der Agenturadresse aus der
Serverliste zu streichen. Nachgemessen gibt es dort gar keine
Serverliste: `bereicheRoh` liefert fuer die Agenturrollen `null`,
und das heisst "der Browser nimmt seine eigene Liste". In dieser
stehen die Rudel-Bretter nicht -- weder in start.html noch in
bereiche.js. Es gab also nichts zu filtern.
Ein Filter, den nichts erreicht, ist schlimmer als keiner: Er sieht
beim Lesen aus wie der Schutz, und man sucht den echten nicht mehr.
Der echte ist das Tor in workspace-bereiche.js und in der
Video-Route -- beide gemessen in pruef-haus-trennung. */
export function bereicheFuer(person) { export function bereicheFuer(person) {
const liste = bereicheRoh(person); const liste = bereicheRoh(person);
if (liste === null || !Array.isArray(liste) || !liste.length) return liste; if (liste === null || !Array.isArray(liste) || !liste.length) return liste;
@@ -2277,6 +2343,49 @@ const BRETTER_UEBER_SEITEN = {
/** Bretter mit eigener Kachel. Diese darf jeder aus dem Team, der die /** Bretter mit eigener Kachel. Diese darf jeder aus dem Team, der die
* Kachel sieht -- sie sind daraus abgeleitet, nicht abgeschrieben. */ * Kachel sieht -- sie sind daraus abgeleitet, nicht abgeschrieben. */
/** In welches Haus gehoert ein NEU angelegter Eintrag?
*
* ==== WARUM NICHT EINFACH `person.haus` (03.10.2026) ===============
*
* Genau das stand an allen acht Einfuege-Stellen -- und
* pruef-treff.mjs hat gezeigt, dass es falsch ist:
*
* FEHL die Community sieht es SOFORT -- ohne zweiten Klick
* FEHL DogFather sieht den Beitrag
*
* Beide Male legt DogFather ueber die AGENTURADRESSE einen Beitrag
* auf einem Brett des Rudels an (`highlight`, `anschlag`). Mit
* `person.haus` bekam der Beitrag das Haus "agentur" -- und war
* damit fuer die Community auf crew. unsichtbar. Auf einem Brett,
* das es nur dort gibt.
*
* Der richtige Massstab steht schon im Kommentar von
* eintraegeNachHaus() weiter unten: "Das Haus eines Eintrags -- aus
* seinem BEREICH, nicht aus dem Ersteller. `regeln`, `anschlag`,
* `highlight` sind Bretter von Team Dogi, `agentur` gehoert der
* Agentur; das steht fest, unabhaengig davon, wer gerade
* hineingeschrieben hat."
*
* ABER NICHT FUER JEDES BRETT. `live`, `technik` und `community`
* tragen BEIDES: die Kacheln von Team Dogi und die Creator-Akten der
* Agentur. Dort ist die Adresse, auf der jemand schreibt, die beste
* Auskunft, die es gibt -- und die einzige.
*
* Also: Wo das Brett eindeutig ist, entscheidet das Brett. Wo nicht,
* die Adresse. TREFF_BRETTER ist die gepflegte Liste der eindeutigen
* Bretter des Rudels; `agentur` ist das Gegenstueck auf der anderen
* Seite und steht in BEREICHE, nicht hier.
*/
export function hausFuerNeuenEintrag(person, bereich) {
/* Die sieben Bretter des Rudels gibt es nur bei Team Dogi -- egal,
von welcher Adresse aus jemand hineinschreibt. */
if (TREFF_BRETTER.includes(String(bereich))) return "crew";
/* Sonst entscheidet die Adresse. Ohne Adresse (Pruefungen,
127.0.0.1) bleibt es offen -- und ein Eintrag ohne Haus ist in
beiden sichtbar, die bewusst sichere Richtung. */
return person?.haus || null;
}
export const MODI_BEREICHE_ERLAUBT = new Set([ export const MODI_BEREICHE_ERLAUBT = new Set([
...HAND_MIT_TREFF ...HAND_MIT_TREFF
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1]) .map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
@@ -3976,6 +4085,12 @@ function umstellungen(d) {
sobald es die Datei kennt. Was wirklich schuetzt, ist die sobald es die Datei kennt. Was wirklich schuetzt, ist die
Byte-Grenze, nicht diese Zahl. */ Byte-Grenze, nicht diese Zahl. */
["chat_nachrichten", "anhang_dauer", "INTEGER"], ["chat_nachrichten", "anhang_dauer", "INTEGER"],
/* WANN ZULETZT BEARBEITET (03.10.2026) -- NULL heisst: nie.
Filipe: "jeder soll seine eigene nachricht bearbeiten koennen."
Die Spalte traegt die Zeit und nicht nur ein Ja/Nein: "vor zwei
Minuten geaendert" und "gestern geaendert" sind zwei
verschiedene Auskuenfte, und die zweite will man sehen. */
["chat_nachrichten", "bearbeitet_am", "TEXT"],
/* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026). /* WELCHE VORLAGE DAHINTERSTECKT (09.09.2026).
@@ -4384,6 +4499,40 @@ function umstellungen(d) {
console.error("[workspace] Spalte dauerhaft:", fehler?.message); console.error("[workspace] Spalte dauerhaft:", fehler?.message);
} }
/* ==== GELESEN IST GELESEN (03.10.2026) =============================
VanVan im Support, Meldung #15: „Die Modis können die Antworten
auf die Bewerbungen im Bereich eure Aufgaben noch nicht
einklappen. Das wird mit der Zeit unübersichtlich."
Am 30.09. habe ich diese Antworten absichtlich NICHT einklappbar
gemacht, mit der Begruendung: „Eine Antwort, die man erst
aufklappen muss, ist wieder keine." Das stimmt -- fuer eine
Antwort, die man noch nicht gelesen hat. Danach ist es falsch
herum, und genau das meldet sie.
DESHALB NICHT „EINKLAPPBAR", SONDERN „GELESEN". Ein Schalter,
der alles wegklappt, haette die naechste Absage mitversteckt --
die Zeile, die man unbedingt sehen soll. Eine Antwort, die man
mit „Verstanden" quittiert hat, rutscht dagegen hinter eine
zugeklappte Zeile und bleibt nachlesbar. Beides zusammen loest
ihr Problem UND behaelt den Grund von vorgestern.
AM MENSCHEN UND NICHT AM GERAET. Sie haette auch in den
Browserspeicher gekonnt; dann waere dieselbe Antwort auf dem
Handy wieder neu. „Habe ich das gelesen?" ist eine Frage ueber
die Person, nicht ueber das Geraet. */
try {
const spalten = d.prepare("PRAGMA table_info(vorlagen_bewerbungen)").all()
.map((x) => x.name);
if (spalten.length && !spalten.includes("gesehen_am")) {
d.exec("ALTER TABLE vorlagen_bewerbungen ADD COLUMN gesehen_am TEXT");
console.log("[workspace] Spalte gesehen_am an vorlagen_bewerbungen angelegt.");
}
} catch (fehler) {
console.error("[workspace] Spalte gesehen_am:", fehler?.message);
}
/* ===================================================================== /* =====================================================================
WER VERANTWORTLICH IST, BRAUCHT EINE ZUTEILUNGSZEILE (30.09.2026) WER VERANTWORTLICH IST, BRAUCHT EINE ZUTEILUNGSZEILE (30.09.2026)
===================================================================== =====================================================================
@@ -5182,6 +5331,46 @@ export function db() {
); );
CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit); CREATE INDEX IF NOT EXISTS idx_push_verschickt_zeit ON push_verschickt (zeit);
/* WAS HINAUSGING -- das Sendeprotokoll (03.10.2026).
ACHTUNG: In diesem Block duerfen keine schraegen Anfuehrungs-
striche stehen, er liegt in einem Template-Literal. Zweimal
hat genau das den Serverstart zerlegt.
WARUM ES DAS BRAUCHT. Diene meldete "ich bekomme nichts", und
niemand konnte nachsehen, OB etwas hinausging. Es gab nur
push_angemeldet und push_abgemeldet im Protokoll -- kein
Wort darueber, ob je eine Meldung verschickt wurde, an wie
viele Geraete, und was der Dienst geantwortet hat.
Das ist dieselbe Falle wie am 06.09. in VanVans Shop: Zwei
Stunden Suche nach dem Grund, warum eine Mail nicht ankommt,
bevor jemand fragte, ob sie ueberhaupt abgeschickt wurde. Sie
war es, alle, nachweisbar. Eine einzige Abfrage haette am
Anfang stehen muessen statt am Ende.
zuletzt_ok in push_anmeldungen reicht dafuer NICHT: Es sagt
nur, wann zuletzt IRGENDETWAS ankam, nicht was, nicht an wen
sonst, und vor allem nichts ueber die Faelle, in denen gar
nicht erst gesendet wurde -- abgeschaltet, Ruhezeit, kein
Geraet. Genau die sind die haeufigste Antwort auf "ich
bekomme nichts".
Deshalb wird JEDER Ausgang festgehalten, auch der, bei dem
nichts hinausging. Ein Protokoll, das nur Erfolge kennt, kann
die Frage nicht beantworten, fuer die es angelegt wurde. */
CREATE TABLE IF NOT EXISTS push_versand (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zeit TEXT NOT NULL,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
art TEXT NOT NULL,
grund TEXT NOT NULL,
geraete INTEGER NOT NULL DEFAULT 0,
zugestellt INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_versand_zeit ON push_versand (zeit);
CREATE INDEX IF NOT EXISTS idx_push_versand_person ON push_versand (person_id, zeit);
/* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback"). /* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback").
Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in
WhatsApp, und damit ausserhalb dessen, was spaeter noch WhatsApp, und damit ausserhalb dessen, was spaeter noch
@@ -5480,6 +5669,11 @@ export function db() {
entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
entschieden_am TEXT, entschieden_am TEXT,
aufgabe_id INTEGER, aufgabe_id INTEGER,
/* Wann die Person die Antwort darauf zur Kenntnis genommen
hat (03.10.2026, VanVan #15). NULL heisst: noch nicht --
dann steht sie offen da. Siehe die ausfuehrliche
Begruendung bei der Nachruestung weiter oben. */
gesehen_am TEXT,
erstellt TEXT NOT NULL erstellt TEXT NOT NULL
); );
/* EINMAL BEWERBEN, NICHT DREIMAL -- und zwar von der Datenbank /* EINMAL BEWERBEN, NICHT DREIMAL -- und zwar von der Datenbank
@@ -5842,6 +6036,57 @@ export function db() {
geaendert TEXT geaendert TEXT
); );
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
/* =================================================================
WER HAT WELCHEN PUNKT EINES EVENTS GESCHAFFT? (03.10.2026)
Filipe: „ich will das viel geiler viel perfekter."
Ein Agentur-Event ist ein Wettbewerb mit Bedingungen -- „jeden
Tag live", „22 Live-Stunden", „33k Diamanten". Bis heute stand
das als Fliesstext auf der Karte, und jede Creatorin musste
sich selbst merken, wo sie steht. Genau daran stirbt eine
Aktion: nicht am Aufruf, sondern daran, dass niemand seinen
eigenen Stand sieht.
EINE ZEILE IST EIN HAKEN -- eine Person, ein Punkt. Kein Feld
am Eintrag mit einer Liste darin: „jeder fuer sich" heisst,
dass zwanzig Personen am SELBEN Event verschiedene Haken
haben. Dieselbe Ueberlegung steht schon bei termin_wecker.
WARUM EIN SCHLUESSEL AUS DEM TEXT UND KEINE ZEILENNUMMER:
Die Aufgaben stehen als Text in der Spalte
event_aufgaben;
DogFather kann sie jederzeit umschreiben. Haengt der Haken an
„Zeile 2", wandert er beim Einfuegen einer Zeile darueber
stillschweigend auf eine andere Aufgabe -- und jemand hat
plotzlich etwas abgehakt, was er nie gemacht hat. Das ist der
schlimmere Ausgang, weil es niemandem auffaellt.
Haengt er dagegen am TEXT der Zeile, gilt: Zeilen umsortieren
oder daruebersetzen aendert nichts; wird die Aufgabe selbst
umgeschrieben, faellt ihr Haken weg. Letzteres ist richtig --
eine geaenderte Bedingung ist eine andere Bedingung, und ein
Haken daran waere eine Falschauskunft.
Der Schluessel wird in workspace-bereiche.js gebildet
(punktSchluessel); hier steht er nur als Text. Eine zweite
Rechenvorschrift in der Datenbank waere die, die auslaeuft.
ON DELETE CASCADE an beiden Seiten: Ein Haken ohne Event ist
nichts, und einer ohne Person auch nicht. */
CREATE TABLE IF NOT EXISTS event_punkte (
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
punkt TEXT NOT NULL,
gesetzt TEXT NOT NULL,
PRIMARY KEY (eintrag_id, person_id, punkt)
);
/* Fuer „wie weit ist mein Team?" -- die Abfrage geht ueber das
Event, nicht ueber die Person. */
CREATE INDEX IF NOT EXISTS idx_event_punkte_eintrag
ON event_punkte (eintrag_id);
/* Die Tabellen des Rudels folgen weiter unten, gleich nachdem /* Die Tabellen des Rudels folgen weiter unten, gleich nachdem
dieser Bauplan durch ist -- sie haengen an eintraege und dieser Bauplan durch ist -- sie haengen an eintraege und
personen und koennen deshalb nicht davor stehen. Ihr SQL steht personen und koennen deshalb nicht davor stehen. Ihr SQL steht
@@ -6739,6 +6984,64 @@ export function gehoertAufDieseAdresse(person, pfad) {
return ziele.has(pfad); return ziele.has(pfad);
} }
/* =====================================================================
WER SCHON ANGEMELDET IST, SIEHT DIE ANMELDUNG NICHT (06.10.2026)
=====================================================================
Filipe: „ich will dass wenn sich jemand bei der workspace seite
anmeldet, angemeldet bleibt bis er sich selbst abmeldet."
GEMESSEN, BEVOR ETWAS GEBAUT WURDE -- und das hat die Richtung
gedreht. Die Sitzung war nie zu kurz:
* Der Keks gilt 180 Tage und verlaengert sich beim Benutzen.
* In der echten Datenbank lief KEINE der 28 Sitzungen vor
Maerz 2027 ab.
* Am echten Browser: Er uebersteht Neuladen, den Service Worker
und das Schliessen des Browsers.
Trotzdem meldeten sich dieselben Geraete staendig neu an --
DogFathers Android am 05.10. um 22:54 UND um 22:55, gleiche IP,
gleiche Browserkennung. Der Grund: `/workspace/` lieferte die
Anmeldung aus, ohne zu fragen, ob schon jemand angemeldet ist.
Das ist die Adresse, die man tippt, als Lesezeichen hat und
weitergibt. Wer sie oeffnete, sah das Codefeld und tippte den Code
noch einmal -- obwohl seine Sitzung gueltig war. Jede Eingabe legt
eine neue Sitzung an; deshalb standen sieben davon bei einer
Person.
Haette ich die Sitzungsdauer erhoeht -- die naheliegende Antwort --,
waere der Fehler geblieben und die Sicherheit schlechter geworden.
(Die installierte App startet auf `start.html` und war nie
betroffen. Es traf nur den Weg ueber die Adresszeile, also den
haeufigsten.)
WARUM AUF DEM SERVER UND NICHT IM BROWSER: Der erste Entwurf stand
in gate.js und fragte `/api/ich`. Das brauchte eine Verdeckung
gegen das Aufblitzen, eine Notfrist gegen eine haengende Abfrage
und eine Schleifensperre mit Zeitstempel in zwei Dateien -- und
hinterliess bei jedem Aufruf der Anmeldung ein 401 in der
Browserkonsole (pruef-neue-seiten wurde davon zehnmal rot). Hier
ist es eine Zeile: Die Sitzung wird ohnehin bei jeder Anfrage
gelesen.
KEINE SCHLEIFENGEFAHR: Schickt die Startseite jemanden zurueck
(nur bei einer Sitzung, die zwischendurch ungueltig wurde), findet
diese Stelle keine Sitzung mehr und liefert die Anmeldung aus.
BEIDE HAEUSER: Auf der Crew-Adresse liefert `/workspace/` die
Datei crew-index.html; der Weg dorthin ist derselbe, und das Ziel
`start.html` gilt dort ebenso. Der Keks ist host-gebunden -- wer
nur im anderen Haus angemeldet ist, bekommt hier weiterhin die
Anmeldung, genau wie es die Haustrennung vom 24.09.2026 verlangt. */
workspaceRouter.get(
["/workspace/", "/workspace/index.html", "/workspace/crew-index.html"],
(req, res, next) => {
if (!sitzungLesen(req)) return next();
return res.redirect(302, "/workspace/start.html");
});
workspaceRouter.use((req, res, next) => { workspaceRouter.use((req, res, next) => {
const pfad = schrankenPfad(req.path); const pfad = schrankenPfad(req.path);
if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next(); if (!pfad.startsWith("/workspace/") || !pfad.endsWith(".html")) return next();
+5 -5
View File
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur. verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
<!-- Ohne diese drei nimmt ein iPhone einen Bildschirmausschnitt als <!-- Ohne diese drei nimmt ein iPhone einen Bildschirmausschnitt als
Symbol, Android zeichnet die Leiste weiss, und die Seite oeffnet Symbol, Android zeichnet die Leiste weiss, und die Seite oeffnet
ausserhalb der App -- auf einer Seite, die genau erklaeren soll, ausserhalb der App -- auf einer Seite, die genau erklaeren soll,
@@ -33,7 +33,7 @@
schreibt jede Seite app.webmanifest -- auch die hier. --> schreibt jede Seite app.webmanifest -- auch die hier. -->
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<style> <style>
/* Bewusst in der Seite und nicht in einer eigenen Datei: Diese Seite /* Bewusst in der Seite und nicht in einer eigenen Datei: Diese Seite
soll auch dann noch etwas anzeigen, wenn genau das schiefgeht, soll auch dann noch etwas anzeigen, wenn genau das schiefgeht,
@@ -135,7 +135,7 @@
<script src="assets/js/anruf-probe.js?v=202610021752" defer></script> <script src="assets/js/anruf-probe.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -23,25 +23,25 @@
}, },
"icons": [ "icons": [
{ {
"src": "/assets/img/app-symbole/workspace-192.png?v=202610021752", "src": "/assets/img/app-symbole/workspace-192.png?v=202610060113",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512.png?v=202610021752", "src": "/assets/img/app-symbole/workspace-512.png?v=202610060113",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202610021752", "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202610060113",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202610021752", "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202610060113",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+61
View File
@@ -2111,6 +2111,67 @@
color: var(--text-leise); color: var(--text-leise);
} }
/* ---- „Verstanden" und die älteren Antworten (03.10.2026) ---------
VanVan im Support, Meldung #15: „Die Modis können die Antworten auf
die Bewerbungen im Bereich eure Aufgaben noch nicht einklappen. Das
wird mit der Zeit unübersichtlich."
DER KNOPF STEHT RECHTS UND IST KLEIN. Er ist nicht die Hauptsache
der Zeile — die ist die Antwort. `margin-left: auto` schiebt ihn
ans Ende, damit die Augen beim Lesen nicht über ihn stolpern.
44 Pixel hoch bleibt er trotzdem: dieselbe Daumengrenze wie
überall im Haus. */
.vorlagenbrett__antwort-weg {
margin-left: auto;
min-height: 44px;
padding: 0 12px;
font: inherit; font-size: .82rem;
color: var(--text-still);
background: transparent;
border: 1px solid var(--rand);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
}
.vorlagenbrett__antwort-weg:hover,
.vorlagenbrett__antwort-weg:focus-visible {
color: var(--text);
border-color: color-mix(in srgb, var(--akzent) 55%, var(--rand));
}
.vorlagenbrett__antwort-alle { justify-self: start; margin-top: 2px; }
/* DER KASTEN MIT DEN GELESENEN IST LEISER. Er steht da, um nachlesen
zu können — nicht, um gesehen zu werden. Deshalb ohne Rahmen und
ohne eigenen Grund: Sonst stünden zwei gleich laute Kästen
untereinander, und der Unterschied zwischen „neu" und „gelesen"
wäre weg. */
.vorlagenbrett__antwort[data-alt="ja"] {
border-color: transparent;
background: transparent;
padding: 0;
gap: 6px;
}
.vorlagenbrett__antwort-kopf {
display: flex; align-items: center; gap: 8px;
min-height: 44px;
padding: 0 4px;
font: inherit; font-size: .84rem;
color: var(--text-still);
background: transparent; border: 0;
cursor: pointer;
text-align: left;
}
.vorlagenbrett__antwort-kopf:hover,
.vorlagenbrett__antwort-kopf:focus-visible { color: var(--text); }
/* KEINE EIGENE GROESSE FUER DEN PFEIL. Hier stand `.7rem` = 11,2 px --
`pruef-css-klassen` hat es gemeldet, und zu Recht: Unter 11,5 px
faengt im Haus die Grenze an, ab der man zusammenkneift. Dass es
„nur ein Zeichen" ist, aendert daran nichts; geerbt sind es
.84rem, und das Dreieck ist damit immer noch kleiner als der
Text daneben wirkt. */
.vorlagenbrett__antwort-pfeil { line-height: 1; }
/* ---- Was wir schon abgesagt haben -------------------------------- /* ---- Was wir schon abgesagt haben --------------------------------
ZUGEKLAPPT, solange niemand hinsieht. Es ist eine Auskunft zum ZUGEKLAPPT, solange niemand hinsieht. Es ist eine Auskunft zum
Nachschlagen, kein Aushang: Eine Liste von Absagen, die immer offen Nachschlagen, kein Aushang: Eine Liste von Absagen, die immer offen
+896 -76
View File
@@ -543,85 +543,28 @@
Zustand zusaetzlich als WORT da ("läuft bis 30.09.2026"). Zustand zusaetzlich als WORT da ("läuft bis 30.09.2026").
===================================================================== */ ===================================================================== */
.event-zeitraum { /* ---------------------------------------------------------------------
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; DIE ALTEN BAUSTEINE SIND AM 03.10.2026 WEGGEFALLEN.
margin: 2px 0 10px;
font-size: .82rem;
}
.event-zeitraum__marke {
--f: var(--k-neutral);
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px;
border: 1px solid color-mix(in srgb, var(--f) 34%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--f) 12%, transparent);
color: color-mix(in srgb, var(--f) 82%, #fff);
font-weight: 600; letter-spacing: .01em;
font-variant-numeric: tabular-nums;
}
/* Der Punkt davor ist reine Zierde und darf deshalb NICHT vorgelesen
werden -- ein Screenreader saehe sonst ein Aufzaehlungszeichen mitten
im Satz. */
.event-zeitraum__marke::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--f); flex: none;
}
.event-zeitraum[data-stand="laeuft"] .event-zeitraum__marke { --f: var(--k-gut); }
.event-zeitraum[data-stand="kommt"] .event-zeitraum__marke { --f: var(--k-mittel); }
.event-zeitraum[data-stand="vorbei"] .event-zeitraum__marke { --f: var(--k-neutral); }
.event-zeitraum__spanne {
color: var(--text-leise);
font-variant-numeric: tabular-nums;
}
/* Ein vorbeigelaufenes Event bleibt lesbar, tritt aber zurueck. Kein Hier standen `.event-zeitraum*` und `.event-block*` -- die
display:none -- was gelaufen ist, will man nachschlagen koennen. Zeitraumzeile ueber der Karte und die zwei gleich aussehenden
Gedaempft wird nur die Flaeche, nicht die Schrift: Text unter 4.5:1 grauen Kaesten fuer Aufgaben und Regeln. Beide erzeugt bereich.js
waere nicht mehr lesbar, und "alt" ist kein Grund, jemanden nicht mehr: Der Zeitraum steht jetzt auf der Buehne (einmal statt
auszuschliessen. */ dreimal), und aus den Kaesten sind Listen geworden, die man abhaken
.eintrag-karte[data-event="vorbei"] { opacity: .82; } kann. Die neuen Regeln stehen weiter unten unter "AGENTUR-EVENTS:
DIE KARTE ALS AUSHANG".
.event-block { SIE WURDEN ENTFERNT UND NICHT AUFGEHOBEN: Eine Regel, die auf nichts
margin: 10px 0 0; mehr zeigt, sieht beim naechsten Lesen aus wie eine, die etwas tut
padding: 10px 12px; -- und man sucht den Fehler dann an einer Stelle, an der nichts
border: 1px solid var(--rand); passiert. Was sie aussagten, steht in der Begruendung der neuen
border-radius: var(--radius-klein); Regeln; die Geschichte selbst steht in git.
background: rgba(255, 255, 255, .022);
}
.event-block__schild {
margin: 0 0 4px;
/* .74rem = 11.84 px, nicht .7rem. Die Schriftgroessen-Pruefung zieht
die Grenze bei 11.5 px, und sie hat recht: In Grossbuchstaben mit
Sperrung wirkt eine Schrift kleiner, als die Zahl sagt -- und das
hier ist die Beschriftung, an der man die Regeln vom Aufgabenteil
unterscheidet. */
font-size: .74rem; font-weight: 700;
letter-spacing: .09em; text-transform: uppercase;
color: var(--text-still);
}
.event-block__text {
margin: 0;
font-size: .87rem; line-height: 1.6; color: var(--text);
/* pre-line, nicht pre-wrap: Zeilenumbrueche bleiben (die Aufzaehlung
ist der Sinn dieser Felder), aber eine versehentlich eingefuegte
Einrueckung aus einer Chatnachricht reisst die Karte nicht auf. */
white-space: pre-line;
overflow-wrap: anywhere;
}
/* .feld-hinweis steht seit dem 07.09.2026 in aufgaben.css -- bei den Ebenfalls weg: `.eintrag-karte[data-event="vorbei"] { opacity: .82 }`.
uebrigen Formularstilen. Grund: Die Dashboard-Seite braucht ihn jetzt Ein abgelaufenes Event war damit kaum von einem laufenden zu
auch (Creator anlegen), laedt aber diese Datei hier nicht. Ein unterscheiden, und der Text wurde schlechter lesbar, ohne dass es
Formularbaustein, der in der Bereichsdatei liegt, ist nur so lange etwas brachte. Jetzt tritt nur das BILD zurueck.
richtig, wie ihn keine zweite Seite braucht. */ --------------------------------------------------------------------- */
@media (max-width: 560px) {
/* Auf dem Handy untereinander: Marke und Spanne nebeneinander
ergaeben bei "vorbei seit 30.09.2026" einen Umbruch mitten im
Datum. */
.event-zeitraum { gap: 6px; }
.event-zeitraum__spanne { flex-basis: 100%; }
}
/* ===================================================================== /* =====================================================================
RÜCKMELDUNG: „fürs Team" oder „nur an DogFather" (10.09.2026) RÜCKMELDUNG: „fürs Team" oder „nur an DogFather" (10.09.2026)
@@ -2195,3 +2138,880 @@ body[data-ton] .kopf-zeile::before {
display: grid; gap: 10px; display: grid; gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
} }
/* =====================================================================
AGENTUR-EVENTS: DIE KARTE ALS AUSHANG (03.10.2026)
Filipe, mit einem Bildschirmfoto des Oktober-Events: "ich will das
viel geiler viel perfekter. und auch wenn man die eintraegt und so
soll viel mehr perfektioniert und uebersichtlicher sein. mach was
hoch profissionelles bitte."
WAS VORHER GEMESSEN WURDE (1280 px, eine einzige Karte):
Kartenhoehe 1196 px -- Fenster ist 900 px
davon Banner 638 px -- 53 %, ohne einen Buchstaben
Titel steht bei 1482 px -- zweimal scrollen bis zum Namen
Titelgroesse 15,36 px -- 1,4 px mehr als der Fliesstext
Etikett 10,88 px -- unter der eigenen Grenze (11,5)
Datum 3 x -- in drei Zeilen untereinander
Knoepfe 4 x gleich -- "loeschen" wie "zur Aufgabe"
WAS SICH DARAUS ERGIBT: Das Bild bleibt gross, traegt aber den Titel.
Der Zeitraum steht einmal, mit der Antwort davor ("noch 13 Tage").
Aufgaben werden zu einer Liste, die man abhaken kann. Und "loeschen"
sieht nicht mehr aus wie "zur Aufgabe".
AUGENSCHONEND BLEIBT ES: keine neuen Signalfarben, kein Leuchten,
kein Blinken. Die Toene kommen aus der vorhandenen Palette
(--k-gut / --k-mittel / --k-neutral / --akzent), die Bewegung ist
dieselbe wie ueberall (--tempo) und faellt bei
prefers-reduced-motion ganz weg.
===================================================================== */
/* Eine Eventkarte traegt ihre eigene Polsterung: Die Buehne geht bis an
den Rand, der Rest nicht. Deshalb hier null -- die Abstaende setzen
die Teile selbst. */
.eintrag-karte[data-eventkarte="ja"] {
padding: 0;
overflow: hidden;
/* Der farbige Streifen links kam von der Dringlichkeit und sagte bei
einem Event nichts: Jedes Event stand auf "mittel", also hatte
jedes denselben blauen Strich. Den Zustand traegt jetzt die Buehne,
und zwar in der Farbe, die ihn bedeutet. */
border-left-width: 1px;
}
/* ---------------------------------------------------------------------
DIE BUEHNE
--------------------------------------------------------------------- */
.ev-buehne {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* DIE HOEHE IST GEDECKELT, UND ZWAR ZWEIFACH.
aspect-ratio haelt die Form bei schmalen Karten (sonst waere die
Buehne auf dem Handy ein Briefschlitz), max-height faengt die
breiten ab: Bei 1230 px Kartenbreite waeren 2.6/1 immer noch
473 px -- mehr als ein Drittel eines Laptopbildschirms fuer ein
Bild, auf dem der Titel steht.
Gemessen: 638 px vorher, 300 px jetzt. */
/* `width: 100%` IST PFLICHT, NICHT ZIERDE (gemessen 03.10.2026).
Ohne sie schrumpft `aspect-ratio` zusammen mit `max-height` auch
die BREITE: In der ersten Fassung war die Buehne 780 px breit in
einer 1230 px breiten Karte -- das Banner stand als Kachel links,
rechts daneben lag ein Drittel Leere. Das sah aus wie ein
Ladefehler und war eine Rechenregel: 300 px Hoehe x 2.6 = 780 px.
Mit fester Breite berechnet aspect-ratio nur noch die Hoehe
(1230 / 2.6 = 473 px), und max-height deckelt sie auf 300. */
width: 100%;
aspect-ratio: 2.6 / 1;
min-height: 170px;
max-height: 300px;
/* DIE SEITLICHE POLSTERUNG HAT EINEN NAMEN, weil der Schriftgrund
darunter sie braucht: Er muss genau bis an den Rand der Buehne
reichen und keinen Schritt weiter. Mit einer zweiten festen Zahl
ging das schief -- auf dem Handy betraegt die Polsterung 14 px,
der Grund rechnete aber weiter mit 20, und die Karte war 6 px
breiter als ihr Fenster (gemessen 03.10.2026, scrollWidth gegen
clientWidth).
Gefunden hat das die Pruefung, nicht das Auge: `overflow: hidden`
an der Karte hat den Ueberhang unsichtbar gemacht. Und mein erster
Suchlauf nach dem Verursacher kam leer zurueck -- ein ::before ist
kein Element, `querySelectorAll("*")` findet es nicht. */
--ev-rand: 20px;
padding: 44px var(--ev-rand) 16px;
background:
radial-gradient(120% 140% at 18% 0%,
color-mix(in srgb, var(--violett, #8a76ff) 26%, transparent), transparent 62%),
linear-gradient(160deg,
color-mix(in srgb, var(--akzent, #3fbdf5) 16%, transparent),
rgba(255, 255, 255, .02));
}
/* OHNE BILD WIRD DIE BUEHNE FLACHER. Ein Farbverlauf braucht keine
300 px, um zu wirken -- und eine halbleere Flaeche ueber drei Zeilen
Text sieht aus, als fehle etwas. */
.ev-buehne:not([data-mitbild="ja"]) {
aspect-ratio: auto;
min-height: 0;
padding-top: 18px;
}
.ev-buehne__bild {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
/* NICHT MITTIG, SONDERN ETWAS HOEHER: Auf einem Event-Banner steht
die Aussage meistens im oberen Drittel ("Schaffst du es, ab HEUTE
bis zum 16.10.2026"), waehrend unten die Preise stehen. Wird
beschnitten, soll die Aussage uebrig bleiben. */
object-position: center 38%;
}
/* DER SCHLEIER MACHT DIE SCHRIFT LESBAR, UND ZWAR IMMER.
Welches Bild jemand spaeter hochlaedt, weiss heute niemand -- ein
helles Banner ergaebe ohne ihn helle Schrift auf Hell. Deshalb kein
"nur bei hellen Bildern": Der Verlauf liegt grundsaetzlich darunter.
Nach oben laeuft er aus, damit das Bild oben unberuehrt bleibt; das
ist die Haelfte, die man sehen will. */
.ev-buehne__schleier {
position: absolute; inset: 0;
/* DIE KURVE IST NACHGEMESSEN, NICHT GESCHAETZT (03.10.2026).
Die erste Fassung lief schon bei 26 % der Hoehe auf .82 Deckung --
damit war von einem Banner praktisch nichts mehr uebrig, und genau
das Bild, das Filipe hochlaedt, war weg. Jetzt ist der Schleier
dort stark, wo die Schrift steht (unterste 30 %), und laeuft
darueber schnell aus: Ab der Haelfte sieht man das Banner fast
unberuehrt.
DIE UNTERSTE STUFE BLEIBT KRAEFTIG. Sie ist es, die den Titel auch
auf einem weissen Banner lesbar haelt -- und was jemand spaeter
hochlaedt, weiss heute niemand. Nachgemessen wird das Ergebnis am
Bildpunkt (siehe pruef-eventkarte.mjs), nicht an dieser Zahl. */
background: linear-gradient(to top,
rgba(5, 8, 14, .92) 0%,
rgba(5, 8, 14, .78) 18%,
rgba(5, 8, 14, .46) 34%,
rgba(5, 8, 14, .16) 52%,
rgba(5, 8, 14, .02) 74%,
transparent 100%);
pointer-events: none;
}
/* Ohne Bild gibt es nichts abzudunkeln -- der Verlauf wuerde dann nur
den eigenen Farbverlauf zumatschen. */
.ev-buehne:not([data-mitbild="ja"]) .ev-buehne__schleier { display: none; }
/* ==== DIE SCHRIFT BRAUCHT IHREN EIGENEN GRUND (03.10.2026) ==========
NACHGEMESSEN, UND ZWAR AM HAERTEFALL: Mit einem fast weissen Banner
(250,248,252) stand "November Challenge" in heller Schrift auf
hellgrauem Grund -- unlesbar. Der Schleier allein reicht dafuer
nicht: Er ist ein Verlauf ueber die ganze Buehne und muesste, um
auch Weiss zu decken, so kraeftig sein, dass von einem dunklen
Banner nichts uebrig bliebe. Ein Wert, der fuer beide Faelle passt,
existiert nicht.
ALSO ZWEI EBENEN STATT EINER:
der Schleier -- sanft, fuer das Gesamtbild
dieser Grund -- kraeftig, aber nur hinter der Schrift
Er reicht 34 px ueber den Text hinaus und laeuft nach oben aus,
damit keine sichtbare Kante entsteht. Die Deckung unten (.93) ist
nicht geschaetzt: Sie ist der Wert, bei dem der Titel auch auf
reinem Weiss ueber 4,5:1 kommt -- gemessen am Bildpunkt in
pruef-eventkarte.mjs, nicht gerechnet.
WARUM NICHT EINFACH EIN KASTEN: Eine sichtbare Flaeche unter dem
Titel saehe aus wie ein Balken, der ueber dem Bild klebt. Der
Verlauf gehoert zur Buehne und faellt nicht auf -- bis man ihn
braucht. */
/* ---- "Banner ganz" ---------------------------------------------------
Oben rechts, klein, und nur dort, wo es ein Bild gibt. Er liegt UEBER
dem Schleier (der Schleier hat `pointer-events: none`, trotzdem
braucht der Knopf seine eigene Ebene) und bringt seinen eigenen
dunklen Grund mit -- auf einem hellen Banner waere er sonst
unsichtbar, genau wie es dem Titel erging. */
.ev-buehne__ganz {
position: absolute; top: 12px; right: 12px;
display: inline-flex; align-items: center; gap: 7px;
/* 34 px hoch mit 10 px Polsterung: Auf dem Handy wird daraus unten
ein 44-px-Ziel. */
padding: 7px 11px;
border: 1px solid rgba(255, 255, 255, .16);
border-radius: 999px;
background: rgba(8, 11, 18, .72);
backdrop-filter: blur(7px);
color: #dce7f3;
font-size: .74rem; font-weight: 600;
text-decoration: none;
transition: background var(--tempo, .22s), border-color var(--tempo, .22s);
}
.ev-buehne__ganz:hover {
background: rgba(8, 11, 18, .9);
border-color: rgba(255, 255, 255, .3);
}
.ev-buehne__ganz:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 2px;
}
/* Die Lupe ist gezeichnet, kein Schriftzeichen: Ein Symbol aus der
Schrift faellt auf manchen Systemen aus oder kommt als Kaestchen. */
.ev-buehne__ganz-zeichen {
flex: none; position: relative;
width: 13px; height: 13px;
border: 1.5px solid currentColor;
border-radius: 3px;
}
.ev-buehne__ganz-zeichen::before,
.ev-buehne__ganz-zeichen::after {
content: ""; position: absolute;
background: currentColor;
}
.ev-buehne__ganz-zeichen::before { left: 2.5px; top: 5px; width: 6px; height: 1.4px; }
.ev-buehne__ganz-zeichen::after { left: 4.8px; top: 2.7px; width: 1.4px; height: 6px; }
@media (prefers-reduced-motion: reduce) {
.ev-buehne__ganz { transition: none; }
}
@media (max-width: 560px) {
.ev-buehne__ganz { top: 9px; right: 9px; padding: 9px 12px; }
/* Auf schmalen Schirmen nur die Lupe -- "Banner ganz" neben einem
Titel, der ohnehin umbricht, kostet eine Zeile. Die Beschriftung
bleibt fuer Vorleseprogramme erhalten (aria-label), sie wird nur
nicht gezeigt. */
.ev-buehne__ganz-wort {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
}
.ev-buehne__inhalt { position: relative; }
.ev-buehne[data-mitbild="ja"] .ev-buehne__inhalt::before {
content: "";
position: absolute;
left: calc(-1 * var(--ev-rand)); right: calc(-1 * var(--ev-rand));
bottom: -16px; top: -34px;
background: linear-gradient(to top,
rgba(4, 7, 12, .93) 0%,
rgba(4, 7, 12, .90) 52%,
rgba(4, 7, 12, .62) 82%,
rgba(4, 7, 12, 0) 100%);
pointer-events: none;
}
/* Die Kinder muessen UEBER diesen Grund -- ein absolut gesetztes
::before liegt sonst ueber allem, was selbst nicht positioniert ist,
und der Titel waere verschwunden. Genau so ist es beim ersten
Versuch passiert. */
.ev-buehne__kopf, .ev-zeit, .ev-zeitbalken { position: relative; }
/* ---- Titelzeile auf der Buehne -------------------------------------- */
.ev-buehne__kopf { align-items: center; gap: 8px 10px; }
/* DER TITEL IST DER TITEL (gemessen: vorher 15,36 px, also 1,4 px mehr
als der Text darunter -- das liest niemand als Ueberschrift).
clamp statt fester Zahl: Eine feste Groesse ist eine Rechnung von
gestern, sobald die Karte breiter oder schmaler wird. Hier skaliert
sie mit der Breite und bleibt zwischen 1,15 rem (18,4 px) und
1,55 rem (24,8 px). */
.eintrag-karte[data-eventkarte="ja"] .eintrag-titel {
flex: 1 1 100%;
font-size: clamp(1.15rem, 2.4vw, 1.55rem);
font-weight: 700;
line-height: 1.22;
letter-spacing: -.01em;
color: #f2f6fb;
/* Der Schatten ist keine Zierde: Er haelt den Titel auch dann
lesbar, wenn genau an dieser Stelle ein heller Fleck im Banner
liegt -- und das sieht man einem Bild vorher nicht an. */
text-shadow: 0 2px 10px rgba(0, 0, 0, .75);
}
/* DAS ETIKETT: 10,88 px waren unter der projekteigenen Grenze von
11,5 px -- angehoben wurde es bisher nur auf dem Handy. Hier gilt
dieselbe Groesse auf jedem Geraet; eine Grenze, die nur bei schmalen
Fenstern gilt, ist keine Grenze. */
.eintrag-karte[data-eventkarte="ja"] .marke-art {
font-size: .75rem;
padding: 2px 10px;
background: rgba(10, 14, 22, .62);
border: 1px solid rgba(255, 255, 255, .14);
backdrop-filter: blur(6px);
text-shadow: none;
}
/* ---- Die Zeitzeile -------------------------------------------------- */
.ev-zeit {
display: flex; align-items: center; flex-wrap: wrap;
gap: 6px 10px;
margin: 10px 0 0;
font-size: .84rem;
}
/* "noch 13 Tage" -- die Antwort, nicht der Beleg. Deshalb die kraeftige
Marke, waehrend das Datum daneben leise bleibt. */
.ev-noch {
--f: var(--k-neutral, #8e9cb0);
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 12px;
border: 1px solid color-mix(in srgb, var(--f) 40%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--f) 16%, rgba(8, 11, 18, .72));
color: color-mix(in srgb, var(--f) 72%, #ffffff);
font-weight: 650;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ev-noch::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--f); flex: none;
}
.ev-noch[data-stand="laeuft"] { --f: var(--k-gut, #5fc99a); }
.ev-noch[data-stand="kommt"] { --f: var(--k-mittel, #e8b877); }
.ev-noch[data-stand="vorbei"] { --f: var(--k-neutral, #8e9cb0); }
/* DIE LETZTEN TAGE SIND NICHT ROT.
Signalrot waere hier falsch: Ein auslaufendes Event ist kein Fehler,
sondern der Normalfall -- jedes endet. Es wird nur waermer und
kraeftiger, damit man es im Vorbeischauen sieht, ohne erschreckt zu
werden. */
.ev-noch[data-dringend="ja"] {
--f: var(--k-mittel, #e8b877);
font-weight: 700;
}
.ev-spanne {
color: #c3d2e2;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 6px rgba(0, 0, 0, .7);
}
/* ---- Der Zeitbalken -------------------------------------------------- */
.ev-zeitbalken {
position: relative;
height: 4px;
margin-top: 12px;
border-radius: 999px;
background: rgba(255, 255, 255, .14);
overflow: hidden;
}
.ev-zeitbalken__fuellung {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg,
color-mix(in srgb, var(--akzent, #3fbdf5) 70%, transparent),
color-mix(in srgb, var(--violett, #8a76ff) 80%, transparent));
transition: width var(--tempo, .22s) ease;
}
.ev-buehne[data-stand="vorbei"] .ev-zeitbalken__fuellung {
background: rgba(255, 255, 255, .26);
}
@media (prefers-reduced-motion: reduce) {
.ev-zeitbalken__fuellung { transition: none; }
}
/* ---------------------------------------------------------------------
DER KOERPER: Text, Aufgaben, Regeln
--------------------------------------------------------------------- */
.eintrag-karte[data-eventkarte="ja"] .eintrag-text {
margin: 14px 18px 0;
color: var(--text-leise);
/* EINE ZEILE HAT EINE LESBARE LAENGE. Auf 1280 px lief der
Erklaertext ueber 1120 px -- rund 150 Zeichen je Zeile. Das Auge
findet den Anfang der naechsten Zeile dann nicht mehr zuverlaessig;
die gaengige Empfehlung liegt bei 45 bis 75 Zeichen. 72ch ist das
obere Ende davon und passt zu dieser Schriftgroesse. */
max-width: 72ch;
}
/* ZWEI SPALTEN, WENN PLATZ IST -- GEMESSEN, NICHT GERECHNET.
`auto-fit` mit einer Mindestbreite bricht genau dann um, wenn eine
Spalte unter 300 px fiele. Das ist der Unterschied zu einer
Fensterbreite in einer Zahl: Kommt irgendwann ein dritter Block
dazu oder wird die Karte in einer schmaleren Spalte gezeigt, stimmt
es weiterhin, ohne dass jemand die Zahl nachzieht.
(Die Lehre dazu steht im Projektgedaechtnis unter dem 06.09.2026:
eine feste Schwelle, die beim Hinzufuegen des naechsten Elements
nicht nachgezogen wurde, hat zwoelf Seiten lang den falschen Knopf
getroffen.) */
.ev-bloecke {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 14px;
align-items: start;
margin: 14px 18px 0;
}
.ev-bloecke .ev-block { margin: 0; }
.ev-block {
margin: 14px 18px 0;
padding: 13px 15px;
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
background: rgba(255, 255, 255, .022);
}
/* AUFGABEN UND REGELN SEHEN NICHT MEHR GLEICH AUS.
Vorher waren es zwei identische graue Kaesten untereinander -- "was
ich tun soll" und "was dabei gilt" wirkten damit gleich wichtig.
Die Aufgaben sind das, weswegen jemand hier ist; sie bekommen einen
Rand in der Hausfarbe. Die Regeln treten zurueck, ohne zu
verschwinden. */
.ev-block--aufgaben {
border-color: color-mix(in srgb, var(--akzent, #3fbdf5) 24%, var(--rand));
background:
linear-gradient(180deg,
color-mix(in srgb, var(--akzent, #3fbdf5) 7%, transparent),
transparent 58%),
rgba(255, 255, 255, .022);
}
.ev-block--regeln { background: rgba(255, 255, 255, .012); }
/* Alles geschafft: ein ruhiger gruener Rand, kein Konfetti. */
.ev-block--aufgaben[data-fertig="ja"] {
border-color: color-mix(in srgb, var(--k-gut, #5fc99a) 34%, var(--rand));
}
.ev-block__kopf {
display: flex; align-items: baseline;
gap: 10px; margin-bottom: 9px;
}
/* DIE ZAHL GEHOERT ZUR UEBERSCHRIFT, NICHT AN DEN RAND.
Mit `justify-content: space-between` stand "0 von 3" auf einer
1160 px breiten Karte rund einen Meter von dem Wort entfernt, auf
das es sich bezieht -- man liest es dann als zwei verschiedene
Angaben. Jetzt steht es direkt daneben, abgesetzt durch einen
Trennpunkt statt durch Leere. */
.ev-zaehler::before {
content: "·";
margin-right: 8px;
color: var(--text-still);
opacity: .7;
}
.ev-block__schild {
margin: 0;
/* .75rem = 12 px. Nicht kleiner: In Grossbuchstaben mit Sperrung
wirkt eine Schrift kleiner, als die Zahl sagt -- die Begruendung
steht so schon beim alten .event-block__schild. */
font-size: .75rem; font-weight: 700;
letter-spacing: .09em; text-transform: uppercase;
color: var(--text-leise);
}
/* "1 von 3" -- die Zahl gehoert neben die Ueberschrift und nicht
darunter: Sie ist die Zusammenfassung der Liste, nicht ihr erster
Eintrag. tabular-nums, damit sie beim Hochzaehlen nicht springt. */
.ev-zaehler {
font-size: .78rem; font-weight: 650;
font-variant-numeric: tabular-nums;
color: var(--text-still);
white-space: nowrap;
}
.ev-zaehler[data-voll="ja"] { color: var(--k-gut, #5fc99a); }
/* ---- Die Liste ------------------------------------------------------- */
.ev-liste {
list-style: none;
margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 2px;
}
.ev-punkt { margin: 0; }
/* EIN VORBEIGELAUFENES EVENT HAT KEINE KNOEPFE -- und ohne den Knopf
fehlte der Zeile seine Polsterung: Beim ersten Lauf klebte
"20 Live-Stunden" am linken Rand des Kastens, waehrend dieselbe
Liste eine Karte darueber ordentlich eingerueckt stand. Zwei Listen
nebeneinander, die verschieden aussehen, liest man als Fehler.
Deshalb bekommt die reine Anzeigezeile dasselbe Raster wie der
Knopf -- nur eben ohne Hintergrund und ohne Zeiger. */
.ev-punkt:not(:has(.ev-punkt__knopf)):not(.ev-punkt--regel) {
display: flex; align-items: flex-start; gap: 10px;
padding: 9px 10px;
font-size: .9rem; line-height: 1.45;
color: var(--text);
}
/* DER GANZE PUNKT IST DER KNOPF, nicht nur ein Kaestchen daneben.
Ein 16-px-Kaestchen ist auf dem Handy ein Ziel von 16 x 16 px -- die
gaengige Mindestgroesse liegt bei 44 x 44. Wer danebentippt, denkt,
es sei kaputt. Die ganze Zeile zu nehmen loest beides: Das Ziel ist
so breit wie die Karte, und man muss nicht zielen. */
.ev-punkt__knopf {
display: flex; align-items: flex-start; gap: 10px;
width: 100%;
padding: 9px 10px;
margin: 0;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--text);
font: inherit;
font-size: .9rem;
line-height: 1.45;
text-align: left;
cursor: pointer;
transition: background var(--tempo, .22s), border-color var(--tempo, .22s);
}
.ev-punkt__knopf:hover {
background: rgba(255, 255, 255, .04);
border-color: var(--rand);
}
.ev-punkt__knopf:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 1px;
}
.ev-punkt__knopf[disabled] { opacity: .6; cursor: progress; }
@media (prefers-reduced-motion: reduce) {
.ev-punkt__knopf { transition: none; }
}
/* Das Kaestchen. Leer ein Rahmen, voll ein Haken -- und der Haken ist
gezeichnet (clip-path), kein Schriftzeichen: Ein Haken aus der
Schrift sieht auf jedem System anders aus und faellt auf manchen
ganz aus. */
.ev-punkt__kaestchen {
flex: none;
width: 19px; height: 19px;
margin-top: 1px;
/* 62 % waren im Bildschirmfoto kaum auszumachen -- ein Kaestchen,
das man suchen muss, laedt nicht zum Anfassen ein. Auf 92 %
angehoben und mit etwas mehr Fuellung dahinter. */
border: 1.5px solid color-mix(in srgb, var(--text-still) 92%, transparent);
border-radius: 6px;
background: rgba(255, 255, 255, .055);
position: relative;
transition: background var(--tempo, .22s), border-color var(--tempo, .22s);
}
.ev-punkt__kaestchen::after {
content: "";
position: absolute; left: 4px; top: 4px;
width: 9px; height: 5px;
border-left: 2px solid #07101a;
border-bottom: 2px solid #07101a;
transform: rotate(-45deg) scale(.6);
opacity: 0;
transition: opacity var(--tempo, .22s), transform var(--tempo, .22s);
}
.ev-punkt[data-abgehakt="ja"] .ev-punkt__kaestchen {
background: var(--k-gut, #5fc99a);
border-color: var(--k-gut, #5fc99a);
}
.ev-punkt[data-abgehakt="ja"] .ev-punkt__kaestchen::after {
opacity: 1; transform: rotate(-45deg) scale(1);
}
@media (prefers-reduced-motion: reduce) {
.ev-punkt__kaestchen, .ev-punkt__kaestchen::after { transition: none; }
}
/* ABGEHAKT WIRD NICHT DURCHGESTRICHEN.
Durchgestrichener Text ist schlecht lesbar, und man will ihn
weiterhin lesen koennen -- "22 Live-Stunden" bleibt die Bedingung,
auch wenn sie erfuellt ist. Der Haken sagt es bereits; die Schrift
tritt nur leicht zurueck. */
.ev-punkt[data-abgehakt="ja"] .ev-punkt__text { color: var(--text-leise); }
.ev-punkt__text { flex: 1 1 auto; overflow-wrap: anywhere; }
/* Die Regelliste: dasselbe Raster, aber ein Strich statt eines
Kaestchens -- man sieht auf einen Blick, dass hier nichts zu tun
ist. */
.ev-punkt--regel {
display: flex; align-items: flex-start; gap: 10px;
padding: 6px 10px;
font-size: .87rem; line-height: 1.5;
color: var(--text-leise);
}
.ev-punkt__zeichen {
flex: none;
width: 19px; height: 19px;
margin-top: 1px;
position: relative;
}
.ev-punkt__zeichen::before {
content: "";
position: absolute; left: 3px; top: 9px;
width: 11px; height: 1.5px;
border-radius: 2px;
background: color-mix(in srgb, var(--text-still) 52%, transparent);
}
/* Der Rueckfall, wenn der Server keine Punkteliste schickt. */
.ev-rohtext {
margin: 0;
font-size: .87rem; line-height: 1.6; color: var(--text);
white-space: pre-line;
overflow-wrap: anywhere;
}
/* ---- Wer macht mit (nur fuer die Leitung) ---------------------------- */
.ev-mitmacher {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-top: 11px; padding-top: 10px;
border-top: 1px solid var(--rand);
}
.ev-mitmacher__schild {
font-size: .7rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase;
color: var(--text-still);
margin-right: 2px;
}
.ev-mitmacher__person {
padding: 2px 9px;
border: 1px solid var(--rand);
border-radius: 999px;
background: rgba(255, 255, 255, .03);
font-size: .76rem;
color: var(--text-leise);
font-variant-numeric: tabular-nums;
}
.ev-mitmacher__person[data-voll="ja"] {
border-color: color-mix(in srgb, var(--k-gut, #5fc99a) 40%, transparent);
color: color-mix(in srgb, var(--k-gut, #5fc99a) 80%, #ffffff);
}
/* ---------------------------------------------------------------------
DER FUSS
--------------------------------------------------------------------- */
.eintrag-karte[data-eventkarte="ja"] .eintrag-fuss {
margin: 16px 0 0;
padding: 12px 18px 15px;
border-top: 1px solid var(--rand);
background: rgba(255, 255, 255, .012);
}
.eintrag-karte[data-eventkarte="ja"] .ampel {
margin: 0; padding: 11px 18px 14px;
border-top: 1px solid var(--rand);
}
/* LOESCHEN SIEHT NICHT MEHR AUS WIE "ZUR AUFGABE".
Gemessen waren es vier gleich grosse, gleich graue Pillen
nebeneinander -- darunter die eine, die man nicht rueckgaengig
machen kann. Sie rutscht jetzt mit einem Abstand nach rechts weg und
bekommt einen warmen Ton. KEIN Signalrot und keine Fuellung: Der
Knopf soll sich unterscheiden, nicht drohen; erst beim Ueberfahren
wird er deutlicher. */
.eintrag-karte[data-eventkarte="ja"] .schritt--gefahr {
margin-left: 10px;
color: color-mix(in srgb, var(--warn, #ff9aa2) 72%, var(--text-still));
border-color: color-mix(in srgb, var(--warn, #ff9aa2) 26%, transparent);
}
.eintrag-karte[data-eventkarte="ja"] .schritt--gefahr:hover {
color: var(--warn, #ff9aa2);
border-color: color-mix(in srgb, var(--warn, #ff9aa2) 52%, transparent);
background: color-mix(in srgb, var(--warn, #ff9aa2) 10%, transparent);
}
/* ---------------------------------------------------------------------
EIN VORBEIGELAUFENES EVENT
--------------------------------------------------------------------- */
/* Die alte Regel dimmte die ganze Karte auf .82 -- damit war ein
abgelaufenes Event kaum von einem laufenden zu unterscheiden, und der
Text wurde schlechter lesbar, ohne dass es etwas brachte.
Jetzt tritt nur das BILD zurueck (entsaettigt und abgedunkelt). Der
Text behaelt seinen vollen Kontrast: "alt" ist kein Grund, jemanden
vom Lesen auszuschliessen. */
.eintrag-karte[data-eventkarte="ja"][data-event="vorbei"] { opacity: 1; }
.ev-buehne[data-stand="vorbei"] .ev-buehne__bild {
filter: saturate(.35) brightness(.62);
}
.ev-buehne[data-stand="vorbei"] { aspect-ratio: 3.6 / 1; max-height: 190px; }
/* ---------------------------------------------------------------------
AUF DEM HANDY
--------------------------------------------------------------------- */
@media (max-width: 560px) {
.ev-buehne {
aspect-ratio: 1.9 / 1;
min-height: 150px; max-height: 230px;
--ev-rand: 14px;
padding: 38px var(--ev-rand) 13px;
}
.ev-buehne[data-stand="vorbei"] { aspect-ratio: 2.6 / 1; max-height: 160px; }
.eintrag-karte[data-eventkarte="ja"] .eintrag-text { margin: 12px 14px 0; }
.ev-bloecke { margin: 12px 14px 0; gap: 12px; }
.ev-block { margin: 12px 14px 0; padding: 12px 12px; }
.ev-bloecke .ev-block { margin: 0; }
.eintrag-karte[data-eventkarte="ja"] .eintrag-fuss { padding: 12px 14px 14px; }
.eintrag-karte[data-eventkarte="ja"] .ampel { padding: 11px 14px 13px; }
/* Das Beruehrziel: 9 px Polsterung plus 19 px Kaestchen ergaeben
37 px. Auf dem Handy wird daraus 44 -- dieselbe Zahl wie bei allen
anderen Knoepfen des Hauses. */
.ev-punkt__knopf { min-height: 44px; align-items: center; }
/* Der Loeschknopf steht auf dem Handy in einer eigenen Zeile; ein
Abstand nach links wuerde ihn dort nur einruecken. */
.eintrag-karte[data-eventkarte="ja"] .schritt--gefahr { margin-left: 0; }
}
/* Hoher Kontrast: Die Zustandsfarben verschwinden dort, also muss die
Form sprechen. */
@media (forced-colors: active) {
.ev-buehne__schleier { display: none; }
.ev-punkt__kaestchen { border: 2px solid CanvasText; }
.ev-punkt[data-abgehakt="ja"] .ev-punkt__kaestchen { background: Highlight; }
.ev-zeitbalken { border: 1px solid CanvasText; }
}
/* =====================================================================
DER ZEILEN-EDITOR UND DIE VORSCHAU (03.10.2026)
Die Begruendung, warum es ihn gibt, steht bei zeilenEditor() in
bereich.js: Ein leeres Textfeld zwingt den Menschen, die
Aufzaehlungszeichen selbst zu tippen -- und dann sehen drei Zeilen
auf drei Arten aus. Hier steht nur, wie er aussieht.
===================================================================== */
.zeileneditor {
display: flex; flex-direction: column;
gap: 6px;
}
.zeileneditor__liste {
display: flex; flex-direction: column;
gap: 6px;
}
.zeileneditor__zeile {
display: flex; align-items: center; gap: 9px;
}
/* Das Aufzaehlungszeichen steht VOR dem Feld und nicht darin: So sieht
man schon beim Tippen, dass es gesetzt wird -- und man kommt gar
nicht erst auf die Idee, selbst eines zu tippen. */
.zeileneditor__zeichen {
flex: none;
width: 16px; height: 16px;
position: relative;
}
.zeileneditor__zeichen::before {
content: "";
position: absolute; left: 4px; top: 6px;
width: 6px; height: 6px; border-radius: 50%;
background: color-mix(in srgb, var(--akzent, #3fbdf5) 58%, transparent);
}
.zeileneditor__feld {
flex: 1 1 auto;
min-width: 0;
/* Dieselben Masse wie alle Felder des Hauses (--feld-ziel usw. in
start.css) -- ein Eingabefeld, das sich von den anderen
unterscheidet, sieht nach einem Fremdkoerper aus. */
min-height: 40px;
padding: 8px 11px;
border: 1px solid var(--rand);
border-radius: 9px;
background: rgba(255, 255, 255, .035);
color: var(--text);
font: inherit;
font-size: .9rem;
}
.zeileneditor__feld:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 1px;
border-color: transparent;
}
/* Der Wegnehmen-Knopf: ein Kreuz, gezeichnet statt getippt -- ein
Mal-Zeichen aus der Schrift sitzt je nach System anders. */
.zeileneditor__weg {
flex: none;
width: 34px; height: 34px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-still);
cursor: pointer;
position: relative;
transition: color var(--tempo, .22s), background var(--tempo, .22s),
border-color var(--tempo, .22s);
}
.zeileneditor__weg::before,
.zeileneditor__weg::after {
content: "";
position: absolute; left: 10px; top: 16px;
width: 12px; height: 1.6px; border-radius: 2px;
background: currentColor;
}
.zeileneditor__weg::before { transform: rotate(45deg); }
.zeileneditor__weg::after { transform: rotate(-45deg); }
.zeileneditor__weg:hover {
color: var(--warn, #ff9aa2);
background: color-mix(in srgb, var(--warn, #ff9aa2) 9%, transparent);
border-color: color-mix(in srgb, var(--warn, #ff9aa2) 24%, transparent);
}
.zeileneditor__weg:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.zeileneditor__weg { transition: none; }
}
.zeileneditor__fuss {
display: flex; align-items: center; gap: 10px;
margin-top: 7px;
flex-wrap: wrap;
}
.zeileneditor__mehr {
padding: 7px 14px;
min-height: 36px;
border: 1px dashed color-mix(in srgb, var(--akzent, #3fbdf5) 34%, var(--rand));
border-radius: 9px;
background: transparent;
color: color-mix(in srgb, var(--akzent, #3fbdf5) 72%, #cfe0f0);
font: inherit; font-size: .84rem; font-weight: 600;
cursor: pointer;
transition: background var(--tempo, .22s), border-color var(--tempo, .22s);
}
.zeileneditor__mehr:hover {
background: color-mix(in srgb, var(--akzent, #3fbdf5) 10%, transparent);
border-style: solid;
}
.zeileneditor__mehr:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.zeileneditor__mehr { transition: none; }
}
/* Der Weg zurueck zum Textfeld steht LEISE daneben: Der Normalfall ist
die Liste, das Textfeld ist der Ausweg fuer den Sonderfall. */
.zeileneditor__wechsel {
padding: 6px 2px;
min-height: 36px;
border: 0; background: transparent;
color: var(--text-still);
font: inherit; font-size: .79rem;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.zeileneditor__wechsel:hover { color: var(--text-leise); }
.zeileneditor__wechsel:focus-visible {
outline: 2px solid var(--akzent, #3fbdf5);
outline-offset: 2px;
border-radius: 6px;
}
/* Der Hinweis "Läuft 14 Tage." -- und die Warnung, wenn das Ende vor
dem Anfang liegt. Die Warnung ist ein WORT und nicht nur eine Farbe:
Wer Farben schlecht unterscheidet, saehe sonst gar nichts. */
#hinweis-dauer[data-warnung="ja"] {
color: var(--warn, #ff9aa2);
font-weight: 600;
}
/* ---------------------------------------------------------------------
DIE VORSCHAU
--------------------------------------------------------------------- */
.ev-vorschau {
margin: 18px 0 4px;
padding: 14px;
border: 1px dashed var(--rand);
border-radius: var(--radius-klein);
background: rgba(255, 255, 255, .012);
}
.ev-vorschau__schild {
margin: 0 0 10px;
font-size: .72rem; font-weight: 700;
letter-spacing: .09em; text-transform: uppercase;
color: var(--text-still);
}
/* Die Vorschau zeigt nur die Buehne -- der Rest der Karte haengt an
Daten, die es vor dem Speichern noch nicht gibt (Knoepfe, Ampel,
Rueckmeldungen). Eine Vorschau, die etwas zeigt, das hinterher
fehlt, ist schlimmer als eine kurze. */
.ev-vorschau .eintrag-karte { margin-bottom: 0; }
+79
View File
@@ -2227,6 +2227,7 @@
.chat-nachricht__antwort-knopf, .chat-nachricht__antwort-knopf,
.chat-nachricht__reag-knopf, .chat-nachricht__reag-knopf,
.chat-nachricht__kopie-knopf, .chat-nachricht__kopie-knopf,
.chat-nachricht__bearb-knopf,
.chat-nachricht__weg-knopf { .chat-nachricht__weg-knopf {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
border: 1px solid transparent; border: 1px solid transparent;
@@ -2248,6 +2249,7 @@
.chat-nachricht__reag-knopf::before, .chat-nachricht__reag-knopf::before,
.chat-nachricht__kopie-knopf::before, .chat-nachricht__kopie-knopf::before,
.chat-nachricht__pin-knopf::before, .chat-nachricht__pin-knopf::before,
.chat-nachricht__bearb-knopf::before,
.chat-nachricht__weg-knopf::before { .chat-nachricht__weg-knopf::before {
content: ""; flex: none; content: ""; flex: none;
width: 13px; height: 13px; width: 13px; height: 13px;
@@ -5734,3 +5736,80 @@
@media (pointer: coarse) { @media (pointer: coarse) {
.chat-art { min-height: 44px; } .chat-art { min-height: 44px; }
} }
/* =====================================================================
BEARBEITEN (03.10.2026)
=====================================================================
Filipe: "jeder soll seine eigene nachricht bearbeiten koennen."
Der Stift traegt sich in die Familie der Handgriffe ein, wie es der
Hinweis weiter oben verlangt -- Form, Polsterung und Rundung kommen
von dort. Hier steht nur, was ihm allein gehoert.
===================================================================== */
.chat-nachricht__bearb-knopf {
--zeichen: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M14.6 5.4l4 4'/><path d='M4.5 19.5h4L19.3 8.7a2.1 2.1 0 0 0-3-3L5.5 16.5z'/></svg>");
}
.chat-nachricht__bearb-knopf:hover {
opacity: 1;
background: color-mix(in srgb, var(--akzent) 14%, transparent);
border-color: color-mix(in srgb, var(--akzent) 32%, transparent);
}
.chat-nachricht__bearb-knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
/* DER VERMERK IST EINE ANGABE, KEIN KNOPF -- also so leise wie die
Zeit daneben und ohne eigene Farbe. Er steht bewusst NICHT in
Klammern: Klammern lesen sich wie eine Nebenbemerkung, und das ist
es nicht. Wer wissen will, wann, faehrt darueber. */
.chat-nachricht__bearbeitet {
font-size: .68rem;
font-style: italic;
opacity: .62;
letter-spacing: .01em;
cursor: help;
flex: none;
}
/* ---- Das Feld an der Nachricht ------------------------------------
Es sitzt IN der Blase, an der Stelle des Textes. Deshalb erbt es
deren Farben und bekommt nur eine eigene Umrandung -- ein eigener
Hintergrund liesse es wie ein zweites Fenster ueber der Nachricht
aussehen, und man wuesste nicht mehr, welche man gerade aendert. */
.chat-bearb { display: grid; gap: 8px; }
.chat-bearb__feld {
width: 100%; box-sizing: border-box;
min-height: 44px;
padding: 9px 11px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--akzent) 42%, transparent);
background: rgba(0, 0, 0, .22);
color: inherit;
font: inherit;
/* 1 rem, nicht kleiner: Unter 16 px zoomt iOS beim Hineintippen die
ganze Seite heran, und danach steht der Verlauf schief. */
font-size: 1rem;
line-height: 1.5;
resize: none;
overflow-y: auto;
}
.chat-bearb__feld:focus-visible { outline: 2px solid var(--akzent); outline-offset: 1px; }
.chat-bearb__reihe { display: flex; gap: 8px; flex-wrap: wrap; }
.chat-bearb__knopf {
min-height: 44px; padding: 8px 16px;
border-radius: 999px;
border: 1px solid var(--rand);
background: transparent;
color: inherit;
font: inherit; font-size: .88rem; font-weight: 600;
cursor: pointer;
transition: background-color .16s ease, border-color .16s ease;
}
.chat-bearb__knopf:hover { border-color: var(--rand-hell); }
.chat-bearb__knopf--ja {
background: color-mix(in srgb, var(--akzent) 18%, transparent);
border-color: color-mix(in srgb, var(--akzent) 46%, transparent);
}
.chat-bearb__knopf--ja:hover { background: color-mix(in srgb, var(--akzent) 26%, transparent); }
.chat-bearb__knopf:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
+127 -4
View File
@@ -728,8 +728,58 @@
lassen. Genau dafür ist er gemacht, und genau deshalb war er unter lassen. Genau dafür ist er gemacht, und genau deshalb war er unter
den Kacheln falsch. den Kacheln falsch.
===================================================================== */ ===================================================================== */
/* =====================================================================
WARUM HIER `fixed` STEHT UND NICHT `relative` (05.10.2026)
=====================================================================
Patrick ueber den Support: „Sophie kann Abschnitt 3 und 4 nicht
bestaetigen, beim druecken kommt ein schwarzer Bildschirm."
Der Dialog war die ganze Zeit da -- nur nicht im Bild. Gemessen am
nachgebauten Fall (Kopie der echten Datenbank, echter Browser):
position absolute statt fixed
oben im Fenster -143 px also oberhalb des Sichtbaren
Ein Browser legt einen Dialog aus `showModal()` mit
`position: fixed; inset: 0; margin: auto` in die Mitte des
SICHTBAREN FENSTERS. Diese Zeile hat das mit `relative`
ueberschrieben -- danach lag er im Dokumentfluss, also nahe dem
SEITENANFANG. Wer weit nach unten gescrollt hatte, sah nur noch
den abdunkelnden Schleier dahinter: einen schwarzen Bildschirm.
WARUM AUSGERECHNET „Abschnitt 3 und 4": Sophie hatte 1 und 2 schon
bestaetigt, dort gibt es keinen Knopf mehr. 3 und 4 waren die
einzigen, die sie ueberhaupt noch druecken konnte -- und sie
stehen am weitesten unten. Der Fehler hing nie an diesen beiden
Abschnitten, sondern an der Scrollhoehe.
ES BETRAF JEDEN DIALOG IM HAUS, nicht nur die Unterweisungen:
22 Aufrufe von `showModal()` in 10 Dateien, vom Loeschen einer
Datei bis zum Eintragen eines Monatsziels. Auf einer kurzen Seite
fiel es nicht auf, weil dort niemand scrollt.
`relative` stand hier fuer die beiden Pseudo-Elemente
(Leuchtschiene und Lichtsaum) -- die brauchen einen positionierten
Vorfahren. `fixed` ist ebenfalls einer, sie bleiben also heil.
`inset: 0` und `margin: auto` schreiben die Zentrierung
ausdruecklich hin, statt sich auf die Vorgabe des Browsers zu
verlassen, die diese Datei ja gerade ueberschreibt.
UND HIER STEHT AUSDRUECKLICH KEIN `max-height` UND KEIN `overflow`.
Beides war mein erster Entwurf und waere ein Rueckschritt gewesen:
Das Rollen ist eine Ebene tiefer laengst geloest, an
`.dialog > form` (siehe weiter unten) -- gemessen am 25.09.2026
ueber vier Bildschirmgroessen, nachdem Miss gemeldet hatte, dass
sie aus einem Fenster nicht mehr herauskam. Dort haengen auch die
festen Kopf- und Fusszeilen. Ein ZWEITER Rollbereich hier darueber
haette genau die wieder geloest, denn `position: sticky` gilt immer
zum naechsten rollenden Vorfahren. Was schon gemessen ist, wird
nicht ein zweites Mal gebaut. */
.dialog { .dialog {
position: relative; position: fixed;
inset: 0;
margin: auto;
width: min(580px, calc(100vw - 32px)); width: min(580px, calc(100vw - 32px));
padding: 0; padding: 0;
border: 1px solid rgba(150, 186, 220, .16); border: 1px solid rgba(150, 186, 220, .16);
@@ -1030,11 +1080,29 @@ body > .dialog__knoepfe { position: static; background: none; backdrop-filter: n
} }
/* DIE VORSCHAU IST KLEIN UND HAT EINE OBERGRENZE. Ein hochkantes /* DIE VORSCHAU IST KLEIN UND HAT EINE OBERGRENZE. Ein hochkantes
Bildschirmfoto vom Handy ist sonst höher als der ganze Dialog, und Bildschirmfoto vom Handy ist sonst höher als der ganze Dialog, und
die Knöpfe darunter wären nicht mehr zu sehen. */ die Knöpfe darunter wären nicht mehr zu sehen.
.nachfrage__vorschau {
SEIT DEM 02.10.2026 KÖNNEN ES MEHRERE SEIN (VanVan, Meldung #11:
„2-3 wären besser"). Sie stehen nebeneinander und brechen um; die
Obergrenze sinkt deshalb von 160 auf 120 Pixel — bei drei Bildern
untereinander wäre sonst der Bestätigungsknopf aus dem Bild. */
.nachfrage__vorschauen {
margin-top: 6px; margin-top: 6px;
display: flex; flex-wrap: wrap; gap: 8px;
}
/* MINDESTENS SO GROSS WIE SEIN KREUZ -- siehe die ausfuehrliche
Begruendung in support.css. Bei einem schmalen Bild legt sich das
44 px breite Kreuz sonst auf den Nachbarn, und wer „das zweite
weg" antippt, loescht das dritte. Gefunden hat das eine Pruefung,
nicht das Auge. */
.nachfrage__vorschau {
position: relative; display: block;
min-width: 56px; min-height: 56px;
}
.nachfrage__vorschau img {
display: block;
max-width: 100%; max-width: 100%;
max-height: 160px; max-height: 120px;
width: auto; width: auto;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--rand); border: 1px solid var(--rand);
@@ -1042,6 +1110,25 @@ body > .dialog__knoepfe { position: static; background: none; backdrop-filter: n
/* Augenschonend: kein greller weißer Kasten, bevor das Bild da ist. */ /* Augenschonend: kein greller weißer Kasten, bevor das Bild da ist. */
background: rgba(255, 255, 255, .03); background: rgba(255, 255, 255, .03);
} }
/* DAS KREUZ LIEGT AUF DEM BILD, damit es nicht die halbe Dialoghöhe
kostet — mit eigenem dunklem Grund, weil ein helles Kreuz auf einem
hellen Bildschirmfoto unsichtbar wäre. 44 Pixel wie jeder Knopf. */
.nachfrage__vorschau-weg {
position: absolute; top: 3px; right: 3px;
min-width: 44px; min-height: 44px;
display: grid; place-items: center;
border: 1px solid var(--rand);
border-radius: 999px;
background: rgba(10, 14, 20, .78);
color: var(--text);
font-size: 1rem; line-height: 1;
cursor: pointer;
}
.nachfrage__vorschau-weg:hover,
.nachfrage__vorschau-weg:focus-visible {
background: rgba(10, 14, 20, .92);
border-color: color-mix(in srgb, var(--akzent) 55%, var(--rand));
}
.nachfrage__form input, .nachfrage__form input,
.nachfrage__form textarea { .nachfrage__form textarea {
width: 100%; width: 100%;
@@ -1058,6 +1145,42 @@ body > .dialog__knoepfe { position: static; background: none; backdrop-filter: n
welche Seitendatei zufällig geladen ist. */ welche Seitendatei zufällig geladen ist. */
.nachfrage__form input { height: 44px; } .nachfrage__form input { height: 44px; }
.nachfrage__form textarea { min-height: 62px; resize: vertical; } .nachfrage__form textarea { min-height: 62px; resize: vertical; }
/* ===== DER HAKEN IM DIALOG (02.10.2026) ===========================
VanVan, Meldung #6: „… noch ‚dauerhaft' einstellen".
DAS KAESTCHEN IST NICHT 100 % BREIT. Die Regel darüber gilt für
JEDES `input` im Formular — ein über die ganze Zeile gezogenes
Ankreuzfeld sieht aus wie ein leeres Textfeld. Deshalb hier eine
eigene Größe, und zwar 22 px: groß genug, um es zu sehen, und der
Fingerbereich kommt vom Etikett drumherum, nicht vom Kästchen.
DAS GANZE ETIKETT IST DIE TREFFFLÄCHE (`<label>` um beides) und
mindestens 44 px hoch — dieselbe Daumengrenze wie überall im Haus.
Wer den Text antippt, setzt den Haken; ein 22-px-Ziel allein wäre
auf dem Handy eine Zumutung. */
.nachfrage__zeile--haken { gap: 2px; }
/* GESPERRT HEISST SICHTBAR GESPERRT. Ein Feld, das nichts mehr tut
und trotzdem normal aussieht, laesst einen tippen und wundern.
`opacity` statt einer anderen Farbe: Das Grau bleibt damit in
jedem Farbschema das halbe Grau des Umfelds — augenschonend und
ohne zweite Palette. */
.nachfrage__zeile[data-aus="ja"] { opacity: .5; }
.nachfrage__form input:disabled { cursor: not-allowed; }
.nachfrage__haken {
display: flex; align-items: center; gap: 10px;
min-height: 44px;
cursor: pointer;
color: var(--text);
}
.nachfrage__form .nachfrage__haken input[type="checkbox"] {
width: 22px; height: 22px;
flex: 0 0 auto;
margin: 0; padding: 0;
accent-color: var(--akzent);
cursor: pointer;
}
.nachfrage__form input:focus, .nachfrage__form input:focus,
.nachfrage__form textarea:focus { .nachfrage__form textarea:focus {
outline: none; outline: none;
+12 -2
View File
@@ -652,7 +652,16 @@ body.reaktion-seite > .kopfleiste { flex: none; }
/* Ein Schild, damit niemand ein stehendes Bild fuer eine /* Ein Schild, damit niemand ein stehendes Bild fuer eine
eingefrorene Kamera haelt. */ eingefrorene Kamera haelt. */
.kamera[data-bildschirm="ja"] .kamera__name::before { .kamera[data-bildschirm="ja"] .kamera__name::before {
content: "Bildschirm · "; /* DER MITTELPUNKT ALS ESCAPE (02.10.2026). Er liegt im
Latin-1-Block — genau dort, wo eine verunglückte Kodierung ihre
Trümmer hinterlässt, und `pruef-zeichen` meldet deshalb jedes
Zeichen von dort in einem `content`. Die Warnung ist berechtigt:
Stünde hier statt des Punktes ein Rest aus einer falsch
gelesenen Datei, sähe man das im Quelltext kaum. Als Escape ist
es eindeutig, zeigt dasselbe und bleibt prüfbar.
Das `\0020` dahinter ist das Leerzeichen: Ohne eigenes Escape
würde CSS es als Fortsetzung der Zahl lesen. */
content: "Bildschirm \00B7\0020";
color: var(--schrift-leise); color: var(--schrift-leise);
} }
.kamera__name { .kamera__name {
@@ -2411,7 +2420,8 @@ body.reaktion-seite > .kopfleiste { flex: none; }
unterscheidet, sieht ihn gar nicht. Ein Wort kann man nicht unterscheidet, sieht ihn gar nicht. Ein Wort kann man nicht
uebersehen. Fuer Hilfsmittel steht daneben `aria-pressed`. */ uebersehen. Fuer Hilfsmittel steht daneben `aria-pressed`. */
.muenzsatz[aria-pressed="true"] .muenzsatz__name::after { .muenzsatz[aria-pressed="true"] .muenzsatz__name::after {
content: " · gewählt"; /* Mittelpunkt als Escape — Begründung oben bei „Bildschirm". */
content: " \00B7\0020gewählt";
color: #9ab7ff; font-weight: 700; color: #9ab7ff; font-weight: 700;
} }
.muenzsatz__wort { .muenzsatz__wort {
+68
View File
@@ -4783,6 +4783,74 @@ body[data-fremde-sicht="ja"]::after {
.glocke[data-lage="verboten"] .glocke__strich, .glocke[data-lage="verboten"] .glocke__strich,
.glocke[data-lage="geht-nicht"] .glocke__strich { opacity: .7; } .glocke[data-lage="geht-nicht"] .glocke__strich { opacity: .7; }
/* ==== "Du bekommst gerade nichts" (03.10.2026) =====================
Am 03.10. nachgemessen: 20 aktive Personen, 8 mit einer Anmeldung.
Zwoelf bekamen keine einzige Benachrichtigung.
RUHIG, NICHT ALARMIEREND. Es ist kein Fehler, sondern eine
Einstellung, die noch niemand getroffen hat -- also sieht er aus
wie eine Kachel und nicht wie eine Warnung. Rot waere hier falsch:
Wer jeden Tag etwas Rotes sieht, sieht bald gar nichts mehr.
Der Hinweis verschwindet von selbst, sobald jemand die Glocke
anschaltet -- `lage()` steht dann auf "an". Es gibt dafuer nichts
zu merken und nichts aufzuraeumen. */
.push-hinweis {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
margin: 0 0 24px;
padding: 15px 20px;
background: rgba(255, 255, 255, .04);
border: 1px solid var(--rand);
/* Die Kante nimmt die Hausfarbe auf -- in beiden Haeusern die
jeweils eigene, weil es `--akzent` ist und keine feste Farbe. */
border-left: 3px solid color-mix(in srgb, var(--akzent) 55%, transparent);
border-radius: 14px;
}
/* `--text`, nicht `--text-leise`: Das ist der eine Satz, der gelesen
werden MUSS. Eine Zeile, die man wegen ihrer Farbe ueberfliegt,
haette den Hinweis gar nicht erst gebraucht. Zeilenlaenge gekappt,
damit er auf breiten Schirmen nicht quer ueber die Seite laeuft. */
.push-hinweis__text {
flex: 1 1 280px; min-width: 0; max-width: 64ch;
color: var(--text);
font-size: 1rem; line-height: 1.55;
}
.push-hinweis__text b { font-weight: 600; }
.push-hinweis__knoepfe { display: flex; gap: 9px; flex: none; flex-wrap: wrap; }
/* Beide Knoepfe sind echte Knoepfe und mindestens 44 px hoch -- ein
Ziel, das man auf dem Handy mit dem Daumen trifft. */
.push-hinweis__knopf {
min-height: 44px; padding: 10px 18px;
border-radius: 999px; border: 1px solid transparent;
font: inherit; font-size: .92rem; cursor: pointer;
transition: background var(--tempo), border-color var(--tempo), color var(--tempo);
}
.push-hinweis__knopf--ja {
background: color-mix(in srgb, var(--akzent) 16%, transparent);
border-color: color-mix(in srgb, var(--akzent) 48%, transparent);
color: var(--akzent);
}
.push-hinweis__knopf--ja:hover {
background: color-mix(in srgb, var(--akzent) 24%, transparent);
border-color: color-mix(in srgb, var(--akzent) 68%, transparent);
}
.push-hinweis__knopf--spaeter {
background: transparent; border-color: var(--rand); color: var(--text-leise);
}
.push-hinweis__knopf--spaeter:hover { border-color: var(--rand-hell); color: var(--text); }
/* Auf dem Handy untereinander, und die Knoepfe nebeneinander in voller
Breite -- nicht zwei Schaltflaechen, die sich 150 px teilen. */
@media (max-width: 560px) {
.push-hinweis { gap: 13px; padding: 14px 16px; }
.push-hinweis__knoepfe { width: 100%; }
.push-hinweis__knopf { flex: 1 1 0; }
}
/* Schmale Schirme: nur die Glocke. Der Text steht weiterhin im /* Schmale Schirme: nur die Glocke. Der Text steht weiterhin im
aria-label, Vorleseprogramme verlieren also nichts. aria-label, Vorleseprogramme verlieren also nichts.
+313 -55
View File
@@ -71,13 +71,66 @@
} }
.s-bild__name { font-size: .8rem; color: var(--text-still); } .s-bild__name { font-size: .8rem; color: var(--text-still); }
.s-vorschau { margin-top: 12px; } /* ===== BIS ZU DREI VORSCHAUEN (02.10.2026) =========================
VanVan, Meldung #11: „2-3 waeren besser." Aus einem Bild wird eine
Reihe -- sie bricht um, statt schmal zu werden: Drei auf 280 Pixel
gequetschte Bildschirmfotos zeigen nichts mehr, und genau das Zeigen
ist der Zweck der Vorschau.
KLEINER, WENN ES MEHRERE SIND, steht hier NICHT als eigene Regel:
`max-height` gilt fuer alle gleich, und der Umbruch erledigt den
Rest. Eine Sonderregel „ab zwei Bildern 140 px" waere eine zweite
Zahl, die beim naechsten Mal vergessen wird. */
.s-vorschau {
margin-top: 12px;
display: flex; flex-wrap: wrap; gap: 10px;
}
/* ==== DER KASTEN MUSS SEIN EIGENES KREUZ TRAGEN (02.10.2026) ======
Gefunden von `pruef-support-bilder`, nicht beim Nachdenken: Beim
Klick auf das Kreuz des ZWEITEN Bildes meldete Playwright, das
Kreuz des DRITTEN liege davor. Grund: Das Kreuz ist 44 px breit
und absolut gesetzt, der Kasten aber nur so breit wie sein Bild.
Bei einem schmalen Bild -- ein Ausschnitt, ein hochkantes Foto in
Briefmarkengroesse -- ragt das Kreuz ueber den Kasten hinaus und
legt sich auf den Nachbarn. Wer dann „das zweite weg" antippt,
loescht das dritte.
`min-width` und `min-height` loesen das an der Ursache: Ein Kasten
ist nie schmaler als der Knopf, der in ihm liegt. */
.s-vorschau__eins {
position: relative; display: block;
min-width: 56px; min-height: 56px;
}
.s-vorschau img { .s-vorschau img {
display: block; max-width: 100%; max-height: 260px; display: block; max-width: 100%; max-height: 200px;
width: auto; height: auto; width: auto; height: auto;
border-radius: var(--radius-klein); border-radius: var(--radius-klein);
border: 1px solid var(--rand); border: 1px solid var(--rand);
} }
/* DAS KREUZ GEHOERT ZUM BILD, nicht in eine Liste darunter. Wer das
dritte Bild falsch erwischt hat, trifft es dort, wo er hinsieht.
44 PIXEL, wie jeder Knopf im Haus -- auf dem Bild liegend, deshalb
mit eigenem dunklem Grund: Ein helles Kreuz auf einem hellen
Bildschirmfoto waere unsichtbar. */
.s-vorschau__weg {
position: absolute; top: 4px; right: 4px;
min-width: 44px; min-height: 44px;
display: grid; place-items: center;
border: 1px solid var(--rand);
border-radius: 999px;
background: rgba(10, 14, 20, .78);
color: var(--text);
font-size: 1rem; line-height: 1;
cursor: pointer;
}
.s-vorschau__weg:hover,
.s-vorschau__weg:focus-visible {
background: rgba(10, 14, 20, .92);
border-color: color-mix(in srgb, var(--akzent) 55%, var(--rand));
}
.s-knopfreihe { .s-knopfreihe {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
@@ -175,7 +228,12 @@
.s-karte { .s-karte {
--s: var(--text-still); --s: var(--text-still);
position: relative; position: relative;
padding: 16px 18px 15px 22px; /* ETWAS MEHR LUFT (03.10.2026). Der Meldungstext ist von 15,2 auf
17 px gewachsen; bei unveraendertem Rand haette die Schrift die
Kante beruehrt und die Karte waere voller gewirkt statt
lesbarer. Links bleibt es breiter als rechts -- dort laeuft die
Leuchtschiene. */
padding: 18px 20px 16px 24px;
margin-bottom: 12px; margin-bottom: 12px;
border: 1px solid var(--rand); border: 1px solid var(--rand);
border-radius: 0; border-radius: 0;
@@ -247,15 +305,53 @@
.s-karte__von { font-size: .88rem; font-weight: 600; color: var(--text); } .s-karte__von { font-size: .88rem; font-weight: 600; color: var(--text); }
.s-karte__zeit { font-size: .78rem; color: var(--text-still); margin-left: auto; } .s-karte__zeit { font-size: .78rem; color: var(--text-still); margin-left: auto; }
/* ==== DER WICHTIGSTE TEXT DER KARTE (03.10.2026) ==================
Filipe: „nur die kacheln noch viel geiler. so dass ich die texte
auch besser erkenne."
GEMESSEN, BEVOR ETWAS ANGEFASST WURDE -- und die naheliegende
Vermutung war falsch:
Kontrast .s-karte__text 15,7 : 1 (noetig 4,5)
Kontrast aller zehn Texte 7,5 bis 15,7
ZEICHEN JE ZEILE 147 (angenehm 45-75)
Am Kontrast lag es also nicht -- der ist ueberall ueppig. Es lag
an der ZEILENLAENGE. Auf 1280 px lief der Text ueber die ganze
Kartenbreite: 1120 px, also knapp 150 Zeichen. Beim Zeilenwechsel
findet das Auge die naechste Zeile nicht mehr sicher; man liest
dieselbe Zeile zweimal oder ueberspringt eine. Das ist der
groesste Hebel beim Lesen, und er kostet nichts.
`72ch` SIND ETWA 72 ZEICHEN, weil `ch` die Breite der Null ist --
eine Einheit, die mit der Schrift mitwaechst. Eine Angabe in
Pixeln waere bei der naechsten Schriftgroesse wieder falsch.
UND EIN STUECK GROESSER: 1.06rem statt .95rem (17 statt 15,2 px).
Das ist der Text, um den es auf dieser Seite geht; die Nebensachen
bleiben klein, damit der Unterschied etwas sagt. */
.s-karte__text { .s-karte__text {
margin: 0; font-size: .95rem; line-height: 1.55; color: var(--text); margin: 0;
font-size: 1.06rem; line-height: 1.6;
color: var(--text);
max-width: 72ch;
/* Absätze bleiben erhalten. Wer „Schritt 1 … Schritt 2 …" /* Absätze bleiben erhalten. Wer „Schritt 1 … Schritt 2 …"
untereinander schreibt, soll es so wiederfinden. */ untereinander schreibt, soll es so wiederfinden. */
white-space: pre-wrap; overflow-wrap: anywhere; white-space: pre-wrap; overflow-wrap: anywhere;
} }
.s-karte__wo { margin: 8px 0 0; font-size: .78rem; color: var(--text-still); } .s-karte__wo { margin: 8px 0 0; font-size: .78rem; color: var(--text-still); }
.s-karte__bild { margin-top: 11px; } /* MEHRERE BILDER STEHEN NEBENEINANDER und brechen um (02.10.2026).
Hier stand nur ein `margin-top` -- ein Kasten fuer genau ein Bild.
DIE SCHRANKE IST DIE BREITE, nicht die Anzahl: 340 Pixel halten die
Reihe auch dann zusammen, wenn sie rechts neben dem Text steht
(ab 760 px, siehe unten). Ohne sie waere die Bildspalte bei drei
Bildern breiter als der Text daneben. */
.s-karte__bild { margin-top: 11px; display: flex; flex-wrap: wrap; gap: 8px; }
.s-karte__bild[data-viele] { max-width: 340px; }
.s-karte__bild[data-viele] img { max-height: 150px; }
.s-karte__bild img { .s-karte__bild img {
display: block; max-width: 100%; max-height: 300px; display: block; max-width: 100%; max-height: 300px;
width: auto; height: auto; width: auto; height: auto;
@@ -292,24 +388,14 @@
Dieselbe Fase, derselbe heller werdende Rand und dieselbe Dieselbe Fase, derselbe heller werdende Rand und dieselbe
Fokusmarke wie oben: Was gleich funktioniert, soll gleich Fokusmarke wie oben: Was gleich funktioniert, soll gleich
aussehen. */ aussehen. */
.s-runde__bild { /* SEIT DEM 02.10. DERSELBE KASTEN wie oben (`.s-karte__bild`), nur
display: block; kleiner. Hier stand eine eigene Klasse `.s-runde__bild` mit fast
margin-top: 8px; denselben zwanzig Zeilen -- zwei Stellen, an denen dasselbe steht,
border-radius: 3px; und beim naechsten Mal wird nur eine davon verbessert. Was bleibt,
} ist der Groessenunterschied, und der ist der ganze Grund: */
.s-runde__bild img { .s-runde .s-karte__bild { margin-top: 8px; }
display: block; max-width: 100%; max-height: 180px; .s-runde .s-karte__bild img { max-height: 180px; }
width: auto; height: auto; .s-runde .s-karte__bild[data-viele] img { max-height: 120px; }
border: 1px solid var(--rand);
clip-path: polygon(8px 0, 100% 0, 100% 100%, 0 100%, 0 8px);
box-shadow: 0 8px 20px -16px rgba(0, 0, 0, .95);
transition: border-color var(--tempo, .18s);
}
.s-runde__bild:hover img,
.s-runde__bild:focus-visible img {
border-color: color-mix(in srgb, var(--akzent) 55%, var(--rand));
}
.s-runde__bild:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
/* ===================================================================== /* =====================================================================
DAS BILD NEBEN DEN TEXT, NICHT DARUNTER (25.09.2026) DAS BILD NEBEN DEN TEXT, NICHT DARUNTER (25.09.2026)
@@ -328,8 +414,15 @@
Breite waere eine Luecke bei jeder Meldung ohne Bild -- und die sind Breite waere eine Luecke bei jeder Meldung ohne Bild -- und die sind
die Mehrzahl. die Mehrzahl.
===================================================================== */ ===================================================================== */
/* `:has(> …)` UND NICHT `:has(…)` -- der Pfeil ist seit dem 02.10.
nicht mehr Geschmackssache. Seit die Rundenbilder denselben Kasten
benutzen (siehe oben), traefe die Regel ohne Pfeil auch auf eine
Karte zu, deren Bild tief im Verlauf steckt: Die Karte bekaeme eine
zweite Spalte, in der nichts steht, und der Text waere ohne Grund
halb so breit. Gefragt ist das EIGENE Bild der Meldung, also das
direkte Kind. */
@media (min-width: 760px) { @media (min-width: 760px) {
.s-karte:has(.s-karte__bild) { .s-karte:has(> .s-karte__bild) {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
@@ -341,12 +434,12 @@
column-gap: 20px; column-gap: 20px;
align-items: start; align-items: start;
} }
.s-karte:has(.s-karte__bild) > .s-karte__kopf { grid-area: kopf; } .s-karte:has(> .s-karte__bild) > .s-karte__kopf { grid-area: kopf; }
.s-karte:has(.s-karte__bild) > .s-karte__text { grid-area: text; } .s-karte:has(> .s-karte__bild) > .s-karte__text { grid-area: text; }
.s-karte:has(.s-karte__bild) > .s-karte__wo { grid-area: wo; } .s-karte:has(> .s-karte__bild) > .s-karte__wo { grid-area: wo; }
.s-karte:has(.s-karte__bild) > .s-antwort { grid-area: antwort; } .s-karte:has(> .s-karte__bild) > .s-antwort { grid-area: antwort; }
.s-karte:has(.s-karte__bild) > .s-karte__tun { grid-area: tun; } .s-karte:has(> .s-karte__bild) > .s-karte__tun { grid-area: tun; }
.s-karte:has(.s-karte__bild) > .s-karte__bild { .s-karte:has(> .s-karte__bild) > .s-karte__bild {
grid-area: bild; margin-top: 0; align-self: start; grid-area: bild; margin-top: 0; align-self: start;
} }
/* 180 UND NICHT 260 (gemessen 25.09.2026). Bei 260 war die Karte /* 180 UND NICHT 260 (gemessen 25.09.2026). Bei 260 war die Karte
@@ -355,7 +448,13 @@
anklickbar ("In voller Groesse ansehen"), eine Vorschau reicht anklickbar ("In voller Groesse ansehen"), eine Vorschau reicht
also. Ein Handy-Bildschirmfoto ist bei 180 px noch als solches zu also. Ein Handy-Bildschirmfoto ist bei 180 px noch als solches zu
erkennen; darum geht es an dieser Stelle. */ erkennen; darum geht es an dieser Stelle. */
.s-karte:has(.s-karte__bild) > .s-karte__bild img { max-height: 180px; } .s-karte:has(> .s-karte__bild) > .s-karte__bild img { max-height: 180px; }
/* UND KLEINER, WENN ES MEHRERE SIND. Bei 180 px stuenden zwei
nebeneinander und das dritte darunter -- die Karte waere hoeher
als bei einem einzigen grossen Bild, und genau das sollte die
zweite Spalte verhindern. Bei 120 px passen drei in zwei Reihen
in dieselbe Hoehe. */
.s-karte:has(> .s-karte__bild) > .s-karte__bild[data-viele] img { max-height: 120px; }
} }
.s-karte__bild a:focus-visible img { outline: 2px solid var(--akzent); outline-offset: 2px; } .s-karte__bild a:focus-visible img { outline: 2px solid var(--akzent); outline-offset: 2px; }
@@ -595,41 +694,200 @@
0 0 0 3px color-mix(in srgb, var(--s-blau) 14%, transparent); 0 0 0 3px color-mix(in srgb, var(--s-blau) 14%, transparent);
} }
/* ---- DER VERLAUF: EINE RUNDE JE ZEILE (25.09.2026) ---------------- /* =====================================================================
DER VERLAUF ALS ZEITACHSE -- EINE FARBE JE RUNDE (03.10.2026)
=====================================================================
Filipe: „mit dem gedanken das manchmal sachen mehrmal nicht sofort Filipe: „ich will diese komplette seite vom support viel
perfekt sein werden." uebersichtlicher, profissioneller, jede runde soll auch immer eine
spezielle farbe haben und anders aufgestellt aufgeteilt sein damit
man einen viel besseren und krasseren ueberblick hat."
Die Runden stehen untereinander an einer Linie -- die Form, in der WAS VORHER DASTAND, an einem Bild mit zwei Runden gemessen: vier
man seit jeher einen VERLAUF liest. Ein Kasten je Runde waere bei Textzeilen untereinander, alle in derselben Farbe. Bei fuenf Runden
vier Runden ein Stapel Kaesten; die Linie haelt sie zusammen. */ muss man die Runden zaehlen, statt sie zu sehen.
.inhalt.support-seite .s-verlauf {
margin: 12px 0 0; DREI TEILE:
padding-left: 14px; · `.s-rundenleiste` alle Runden auf einen Blick, ein Punkt je
border-left: 2px solid color-mix(in srgb, var(--s-lila) 32%, transparent); Runde in ihrer Farbe, mit ihrem Ausgang
display: grid; gap: 12px; · `.s-verlauf` die Zeitachse: eine Linie, Punkte darauf
· `.s-schritt` zwei Sprechrichtungen je Runde, getrennt
DIE FARBE KOMMT ALS ZAHL (`--ton`) aus support.js und wird hier zu
einer Farbe gemacht. Saettigung und Helligkeit stehen NUR hier und
sind fuer alle Toene gleich -- sonst waere Runde drei blasser als
Runde eins, und augenschonend ist das Gegenteil von zufaellig.
AUGENSCHONEND HEISST HIER: 52 % Saettigung, 64 % Helligkeit. Kein
Neon, kein Gluehen. Die Farbe ordnet Zeilen zu; sie schreit nicht.
===================================================================== */
/* ---- Die Leiste: wie oft ging es schon hin und her? --------------- */
.s-rundenleiste {
display: flex; flex-wrap: wrap; gap: 6px;
margin: 12px 0 10px;
}
.s-rundenleiste__punkt {
--farbe: hsl(var(--ton, 250) 52% 64%);
display: grid; place-items: center;
width: 26px; height: 26px;
border-radius: 999px;
font-size: .72rem; font-weight: 800;
color: color-mix(in srgb, var(--farbe) 40%, #ffffff);
background: color-mix(in srgb, var(--farbe) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--farbe) 46%, transparent);
}
/* DER AUSGANG STEHT AM RAND, nicht in der Fuellung: Die Fuellung
gehoert der RUNDE, der Rand dem ERGEBNIS. Zwei Aussagen, zwei
Stellen -- in eine Farbe gemischt waere keine mehr zu lesen. */
.s-rundenleiste__punkt[data-urteil="geht"] {
border-color: color-mix(in srgb, var(--gut, #55d6a0) 60%, transparent);
}
.s-rundenleiste__punkt[data-urteil="nicht"] {
border-color: color-mix(in srgb, #c99a62 62%, transparent);
}
/* NUR FUER VORLESEPROGRAMME. Die Leiste ist ein Bild; was sie sagt,
steht als Satz daneben -- sichtbar waere er eine Dopplung. */
.s-nurlesen {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ---- Die Zeitachse ----------------------------------------------- */
.inhalt.support-seite .s-verlauf {
/* EINE FEINE LINIE TRENNT DIE MELDUNG VOM VERLAUF (03.10.2026).
Vorher lief beides ohne Zaesur ineinander: Der Satz, um den es
geht, und die Vorgeschichte sahen aus wie ein Block. Die Linie
kostet einen Pixel und beantwortet „wo hoert die Meldung auf?". */
margin: 16px 0 0;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, .07);
display: grid; gap: 14px;
}
/* JEDE RUNDE HAENGT AN DER LINIE. Die Linie ist ein Rand an der
Runde selbst und nicht am Behaelter: So traegt jede Runde ihr
eigenes Stueck Achse in ihrer eigenen Farbe -- genau das, was
„jede Runde eine spezielle Farbe" auf dem Bildschirm bedeutet. */
.s-runde {
--farbe: hsl(var(--ton, 250) 52% 64%);
position: relative;
padding-left: 20px;
border-left: 2px solid color-mix(in srgb, var(--farbe) 34%, transparent);
}
/* DER PUNKT AUF DER ACHSE. `inset 0 0 0 2px` statt eines zweiten
Elements: ein Knoten ohne eigenes Markup. */
.s-runde::before {
content: "";
position: absolute;
left: -6px; top: 4px;
width: 10px; height: 10px;
border-radius: 999px;
background: var(--farbe);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--farbe) 18%, transparent);
}
.s-runde__kopf {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px;
} }
.s-runde__kopf { display: flex; align-items: baseline; gap: 8px; }
.s-runde__nr { .s-runde__nr {
font-size: .72rem; font-weight: 800; letter-spacing: .08em; font-size: .72rem; font-weight: 800; letter-spacing: .08em;
text-transform: uppercase; text-transform: uppercase;
color: color-mix(in srgb, #b9a7ff 30%, #ffffff); color: color-mix(in srgb, var(--farbe) 42%, #ffffff);
} }
.s-runde__von { font-size: .78rem; color: var(--text-still); } .s-runde__von { font-size: .78rem; color: var(--text-still); }
.s-runde__antwort { .s-runde__zeit { font-size: .74rem; color: var(--text-leise); margin-left: auto; }
/* ---- „Fruehere Runden zeigen" ------------------------------------
Er steht OBEN im Verlauf, nicht unten: Dort, wo die aelteren
Runden waeren. Ein Knopf am Ende der Liste wuerde bedeuten, dass
das Fehlende unten fehlt -- es fehlt aber oben.
44 Pixel hoch, wie jeder Knopf im Haus. Leise gestaltet: Er ist
ein Weg, keine Nachricht. */
.s-verlauf__mehr {
display: flex; align-items: center; gap: 8px;
min-height: 44px;
padding: 0 4px;
font: inherit; font-size: .82rem;
color: var(--text-still);
background: transparent; border: 0;
cursor: pointer;
text-align: left;
}
.s-verlauf__mehr:hover,
.s-verlauf__mehr:focus-visible { color: var(--text); }
.s-verlauf__pfeil { font-size: .8rem; line-height: 1; }
/* ---- Zwei Sprechrichtungen je Runde ------------------------------- */
/* WER SPRICHT, STEHT AM RAND. Vorher standen beide Saetze als
Absaetze untereinander und sahen gleich aus -- man musste lesen, um
zu wissen, von wem sie sind. Jetzt sagt es die Form. */
.s-schritt {
margin-top: 8px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, .025);
box-shadow: inset 2px 0 0 rgba(255, 255, 255, .12);
}
.s-schritt__schild {
display: block;
/* .74rem = 11,84 px. Unter 11,5 px faengt im Haus die Grenze an,
ab der man zusammenkneift -- `pruef-css-klassen` setzt sie durch.
Mein erster Entwurf hatte .7rem (11,2 px) und waere rot geworden. */
font-size: .74rem; font-weight: 800; letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-still);
}
.s-schritt__text {
margin: 4px 0 0; margin: 4px 0 0;
font-size: .9rem; line-height: 1.5; color: var(--text-leise); /* .95rem = 15,2 px, und dieselbe Zeilenschranke wie oben. Gemessen
waren es hier 149 Zeichen je Zeile. Eine Stufe kleiner als der
Meldungstext: Der Verlauf ist die Vorgeschichte, nicht die
Hauptsache. */
font-size: .95rem; line-height: 1.6; color: var(--text-leise);
max-width: 70ch;
white-space: pre-wrap;
overflow-wrap: anywhere;
} }
/* DAS URTEIL DES MELDERS STEHT EINGERUECKT DARUNTER -- es ist die /* DIE LEITUNG HAT DIE FARBE DER RUNDE -- sie hat sie eroeffnet. */
ANTWORT auf die Zeile darueber, nicht eine zweite Meldung. */ .s-schritt[data-wer="leitung"] {
.s-runde__urteil { box-shadow: inset 2px 0 0 color-mix(in srgb, var(--farbe) 55%, transparent);
margin: 6px 0 0; padding-left: 12px;
border-left: 2px solid rgba(255, 255, 255, .1);
font-size: .86rem; line-height: 1.45; color: var(--text-still);
} }
.s-runde__urteil-wort { font-weight: 750; } .s-schritt[data-wer="leitung"] .s-schritt__schild {
.s-runde__urteil[data-urteil="geht"] .s-runde__urteil-wort { color: #8fe3b0; } color: color-mix(in srgb, var(--farbe) 38%, #ffffff);
.s-runde__urteil[data-urteil="nicht"] .s-runde__urteil-wort { color: #ffb3a7; } }
/* DER MELDER TRAEGT DEN AUSGANG. Gruen fuer „geht wieder" --
der einzige gruene Punkt auf dieser Seite. Bernstein fuer „ging
noch nicht": KEIN ROT. Eine Absage ist kein Fehler und nichts, was
jemand falsch gemacht hat (siehe die Begruendung weiter oben bei
den Bewerbungsantworten); rot laese sich wie ein Urteil ueber den
Menschen. */
.s-schritt[data-urteil="geht"] {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--gut, #55d6a0) 62%, transparent);
background: color-mix(in srgb, var(--gut, #55d6a0) 5%, transparent);
}
.s-schritt[data-urteil="geht"] .s-schritt__schild { color: #8fe3b0; }
.s-schritt[data-urteil="nicht"] {
box-shadow: inset 2px 0 0 color-mix(in srgb, #c99a62 62%, transparent);
background: color-mix(in srgb, #c99a62 5%, transparent);
}
.s-schritt[data-urteil="nicht"] .s-schritt__schild { color: #e8c49a; }
/* WARTET: kein Kasten mit Inhalt, nur ein Vermerk. Er steht da,
damit eine Runde ohne Rueckmeldung nicht aussieht wie eine, bei
der etwas fehlt. */
.s-schritt[data-wer="warte"] {
background: transparent;
box-shadow: inset 2px 0 0 rgba(255, 255, 255, .08);
padding-top: 6px; padding-bottom: 6px;
}
.s-schritt[data-wer="warte"] .s-schritt__schild { color: var(--text-leise); }
/* „Geht wieder" ist der Knopf, den man drueckt, wenn alles gut ist -- /* „Geht wieder" ist der Knopf, den man drueckt, wenn alles gut ist --
er darf als Einziger auf dieser Seite gruen sein. */ er darf als Einziger auf dieser Seite gruen sein. */
+50 -15
View File
@@ -419,7 +419,23 @@
: a.darf_status === true; : a.darf_status === true;
if (darfTun) { if (darfTun) {
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status])); if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status])); /* ==== EINE DAUERHAFTE AUFGABE WIRD NICHT ABGEHAKT ==========
(02.10.2026)
VanVan im Support: „Der Modi kann die dauerhafte Aufgabe
immer noch auf erledigt setzen."
Der Server lehnt es jetzt ab. Hier faellt der Knopf weg,
der die Absage holen wuerde -- dieselbe Ueberlegung wie bei
„Fertig" auf der Zuteilungskarte: Ein Knopf, der eine Absage
holt, ist schlimmer als keiner.
NUR DER LETZTE SCHRITT. „starten" und „zur Freigabe" bleiben
-- auch eine stehende Aufgabe hat einen Anfang, und der
Unterschied zwischen „offen" und „in Arbeit" sagt etwas. */
const wohin = WEITER[a.status];
const beendet = wohin === 'erledigt' && a.dauerhaft && a.darf_beenden === false;
if (wohin && !beendet) schritte.append(knopf(beschriftung(a.status), a, wohin));
} }
/* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten: /* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten:
Auch etwas Abgehaktes kann sich als hinfällig herausstellen. Auch etwas Abgehaktes kann sich als hinfällig herausstellen.
@@ -748,6 +764,25 @@
ziel.textContent = ''; ziel.textContent = '';
if (ich.rolle === 'creator') { ziel.hidden = true; return; } if (ich.rolle === 'creator') { ziel.hidden = true; return; }
/* ==== BEI TEAM DOGI GIBT ES DIESE REIHE NICHT MEHR (03.10.2026)
Filipe, mit Bildschirmfoto der Leiste „FÜR WEN · Alle 3 2 ·
Marina 1 · Diene 0": „brauchen wir nicht auf der aufgaben
seite."
NUR DORT, NICHT BEI SPICY MEDIA. Es ist dieselbe Reihe, aber
nicht dieselbe Aufgabe: Bei Team Dogi stehen darin drei, vier
Modis, die man ohnehin alle auf einem Schirm hat -- ein Filter
für eine Liste, die kürzer ist als er. Auf der Agenturseite
filtern Scouts und Manager damit durch ihre Creator, und das
sind deutlich mehr. Dort bleibt sie.
Getrennt geführt, wie seit dem 24.09. für jeden Umbau: Diese
Zeile ist die ganze Trennung, und `ich.haus` kommt vom Server
-- eine Rollenliste hier wäre die zweite Wahrheit und für
DogFather, der in beiden Häusern arbeitet, sogar falsch. */
if (ich.haus === 'crew') { ziel.hidden = true; return; }
/* Die Liste kommt aus den Aufgaben selbst, nicht aus einer /* Die Liste kommt aus den Aufgaben selbst, nicht aus einer
Personenabfrage: Dann stehen dort genau die Creator, die man Personenabfrage: Dann stehen dort genau die Creator, die man
ohnehin sehen darf -- und niemals einer mehr. */ ohnehin sehen darf -- und niemals einer mehr. */
@@ -764,24 +799,24 @@
/* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather /* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather
waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */ waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */
/* AUF DER TEAM-ADRESSE HEISST DAS NICHT „CREATOR" (24.09.2026). /* HIER STAND BIS ZUM 03.10.2026 EIN DRITTER FALL.
Filipe: „in dieser app gibt es keinen und wird es niemals einen Am 24.09. war aufgefallen, dass die Reihe auf der Team-Adresse
anderen creator geben wie mich." „Alle Creator" hiess, obwohl dort Modis stehen -- Filipe: „in
dieser app gibt es keinen und wird es niemals einen anderen
creator geben wie mich." Sie hiess seitdem dort „Für wen".
Er hat recht, und es stand woertlich auf dem Bildschirm: Die Am 03.10. ist die Reihe auf der Team-Adresse ganz entfallen
Reihe war mit „Alle Creator" beschriftet, und darunter standen (siehe oben). Die Beschriftung „Für wen" ist damit nicht mehr
Modis. Dort gibt es keine Creator -- die Rollen auf crew. sind erreichbar und wurde entfernt statt stehengelassen: Ein Zweig,
rechte Hand, linke Hand und Modi. den niemand mehr erreicht, sieht beim Lesen aus wie ein Fall,
den es gibt -- und beim naechsten Umbau pflegt ihn jemand mit.
`ich.haus` kommt vom Server (siehe /api/ich). Eine Rollenliste Was bleibt, ist die Unterscheidung innerhalb der Agentur:
hier waere die zweite Wahrheit und ausserdem falsch fuer „Deine Creator" stimmt fuer Scout und Manager; bei DogFather
DogFather, der in beiden Haeusern arbeitet. */ waeren es ALLE, und „deine" waere dort eine falsche Auskunft. */
const imTeam = ich.haus === 'crew';
ziel.append(el('span', 'creator-reihe__schild', ziel.append(el('span', 'creator-reihe__schild',
imTeam ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator'));
? 'Für wen'
: (ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator')));
const knopf = (id, name, offen, faellig) => { const knopf = (id, name, offen, faellig) => {
const b = el('button', 'creator-knopf'); const b = el('button', 'creator-knopf');
b.type = 'button'; b.type = 'button';
+830 -30
View File
@@ -374,6 +374,79 @@
return { schluessel: 'laeuft', text: von === bis ? 'heute' : 'läuft bis ' + datum(bis) }; return { schluessel: 'laeuft', text: von === bis ? 'heute' : 'läuft bis ' + datum(bis) };
} }
/* =====================================================================
WIE LANGE NOCH? (03.10.2026)
Die eine Frage, die bei einem Wettbewerb wirklich zaehlt -- und
die Karte hat sie bis heute nicht beantwortet. Dort stand dreimal
dasselbe Datum ("laeuft bis 16.10.2026", "03.10.2026 - 16.10.2026",
"Im Kalender am 03.10.2026") und nirgends die Zahl, die man
eigentlich wissen will.
IN TAGEN, NICHT IN STUNDEN. Ein Event laeuft ueber Wochen; "noch
312 Stunden" muss man erst umrechnen. Am LETZTEN Tag wechselt der
Ton: Dann ist "heute ist der letzte Tag" die Auskunft, und sie
gehoert hervorgehoben.
GERECHNET WIRD AUF TAGEN, nicht auf Zeitpunkten. Ein Event endet
am 16.10. -- also mit dem Ablauf dieses Tages, nicht um 00:00 Uhr.
Wer mit Date.now() rechnet, meldet am Morgen des letzten Tages
"vorbei".
*/
const TAG_MS = 86400000;
const alsTag = (iso) => {
const t = String(iso || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(t) ? new Date(t + 'T00:00:00').getTime() : null;
};
const tageZwischen = (a, b) => Math.round((b - a) / TAG_MS);
/** Der Satz unter dem Titel: "noch 13 Tage", "letzter Tag",
* "startet morgen", "vorbei seit 12 Tagen". */
function eventNoch(e) {
const heuteIso = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const heute = alsTag(heuteIso);
const von = alsTag(e.datum);
const bis = alsTag(e.event_ende || e.datum);
if (heute === null || von === null || bis === null) return null;
if (heute < von) {
const n = tageZwischen(heute, von);
return { wort: n === 1 ? 'startet morgen' : `startet in ${n} Tagen`, dringend: n <= 1 };
}
if (heute > bis) {
const n = tageZwischen(bis, heute);
return { wort: n === 1 ? 'seit gestern vorbei' : `vorbei seit ${n} Tagen`, dringend: false };
}
const n = tageZwischen(heute, bis);
if (n === 0) return { wort: 'heute ist der letzte Tag', dringend: true };
if (n === 1) return { wort: 'noch 1 Tag', dringend: true };
/* "Noch 2 Tage" ist knapp, "noch 3" ist es nicht mehr -- die Grenze
ist gesetzt und nicht gemessen, aber sie steht an EINER Stelle. */
return { wort: `noch ${n} Tage`, dringend: n <= 2 };
}
/** Wie viel des Zeitraums ist herum? 0 bis 1, oder null.
*
* FUER EINEN BALKEN, nicht fuer eine Zahl. "Wie viel Zeit ist noch"
* sieht man an einer Laenge sofort und muss sie nicht lesen -- genau
* wie beim Rangbalken der Wunschliste darueber.
*
* EIN EINTAEGIGES EVENT hat keinen Verlauf (von === bis, Division
* durch null). Dann gibt es keinen Balken: Ein Balken, der bei einem
* Tagesevent immer voll oder immer leer stuende, sagt nichts. */
function eventFortschritt(e) {
const heuteIso = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const heute = alsTag(heuteIso);
const von = alsTag(e.datum);
const bis = alsTag(e.event_ende || e.datum);
if (heute === null || von === null || bis === null || bis <= von) return null;
const anteil = (heute - von) / (bis - von);
return Math.max(0, Math.min(1, anteil));
}
/* Der Abruf steht seit dem 22.09.2026 in assets/js/holen.js. /* Der Abruf steht seit dem 22.09.2026 in assets/js/holen.js.
Er stand ACHTMAL da, Zeichen fuer Zeichen gleich -- und als Er stand ACHTMAL da, Zeichen fuer Zeichen gleich -- und als
die Regel darin sich als falsch herausstellte (jeder 404 die Regel darin sich als falsch herausstellte (jeder 404
@@ -428,8 +501,14 @@
feld.click(); feld.click();
} }
function tuKnopf(text, tu) { /* `klasse` kam am 03.10.2026 dazu, fuer den Loeschknopf an der
const b = el('button', 'schritt', text); Eventkarte. Vorher sahen dort vier Knoepfe gleich aus -- darunter
der eine, den man nicht zuruecknehmen kann. Die Unterscheidung
gehoert an den Knopf und nicht in eine Regel, die ihn ueber seine
Stellung in der Reihe sucht: Kommt ein fuenfter dazu, trifft so
eine Regel den Falschen. */
function tuKnopf(text, tu, klasse) {
const b = el('button', 'schritt' + (klasse ? ' ' + klasse : ''), text);
b.type = 'button'; b.type = 'button';
b.addEventListener('click', async () => { b.addEventListener('click', async () => {
b.disabled = true; melde(''); b.disabled = true; melde('');
@@ -509,10 +588,319 @@
if (ton) document.body.dataset.ton = String(ton); if (ton) document.body.dataset.ton = String(ton);
} }
/* =====================================================================
DIE BUEHNE EINES EVENTS (03.10.2026)
Filipe: "ich will das viel geiler viel perfekter."
GEMESSEN, WARUM: Auf 1280 px war eine Eventkarte 1196 px hoch --
hoeher als das Fenster. 638 px davon, also 53 %, waren das Banner,
und zwar leeres Banner: Der Titel stand DARUNTER, bei 1482 px auf
der Seite. Man musste zweimal scrollen, um zu erfahren, wie das
Event ueberhaupt heisst. Dazu war der Titel 15,36 px gross -- 1,4 px
mehr als der Fliesstext, der darunter stand.
ALSO STEHT DER TITEL JETZT AUF DEM BILD. Das Banner ist kein Anhang
mehr, sondern der Hintergrund des Kopfes: Es behaelt seine Wirkung,
verliert aber die Hoehe, die es ohne Inhalt verbraucht hat. Die
Hoehe ist gedeckelt (CSS), damit ein hochkantes Bild die Karte
nicht erneut aufreisst.
OHNE BANNER GIBT ES TROTZDEM EINE BUEHNE -- dann aus Farbe. Eine
Karte, die je nach Bild zwei voellig verschiedene Formen hat, liest
man als Unordnung; und ein Event ohne Bild ist kein Event zweiter
Klasse.
DIE LESBARKEIT KOMMT NICHT VOM ZUFALL: Ueber dem Bild liegt ein
Verlauf von unten (siehe .ev-buehne__schleier im CSS). Ein heller
Banner wuerde sonst weisse Schrift auf Weiss ergeben -- und genau
das kann man einem Bild, das jemand spaeter hochlaedt, nicht
ansehen. Der Schleier gilt deshalb immer, nicht nur bei dunklen
Bildern. */
function eventBuehne(e, stand, kopf) {
const b = el('header', 'ev-buehne');
b.dataset.stand = stand.schluessel;
const bild = (e.bilder || [])[0];
if (bild) {
b.dataset.mitbild = 'ja';
const img = el('img', 'ev-buehne__bild');
img.src = '/workspace/api/dateien/' + bild.id + '/bild';
/* LEERES alt IST HIER RICHTIG: Das Banner ist Schmuck hinter dem
Titel, der direkt darauf steht. Ein zweites Mal "Bild zu
Oktober Event" vorgelesen zu bekommen, nachdem der Titel schon
kam, ist Laerm, keine Auskunft. */
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
/* Faellt das Bild aus, bleibt die Buehne stehen -- nur eben als
Farbe. Ein kaputtes Bildsymbol mitten im Kopf saehe aus, als
sei die Seite defekt. */
img.addEventListener('error', () => { img.remove(); delete b.dataset.mitbild; });
b.append(img);
/* ==== DAS BANNER IN VOLLER GROESSE (03.10.2026) ==============
EINE VERSCHLECHTERUNG, DIE BEIM NACHSEHEN AUFFIEL: Vorher war
das Bild ein Verweis -- ein Klick zeigte es ganz. Als es zur
Buehne wurde, fiel dieser Weg weg, und das ist bei genau
diesen Bildern der falsche Verlust: Auf Filipes Oktober-Banner
steht die eigentliche Ansage IM Bild ("Schaffst du es, ab
HEUTE bis zum 16.10.2026") samt der drei Bedingungen. Die
Buehne zeigt davon einen Ausschnitt -- sie muss, sonst waere
sie wieder 638 px hoch.
DESHALB EIN EIGENER KNOPF und nicht die ganze Flaeche: Auf der
Buehne stehen Titel und Zeitangabe, und Text, den man nicht
markieren kann, ohne dass sich ein Fenster oeffnet, ist
aergerlich. Ein kleiner Knopf oben rechts ist eindeutig --
und er sagt mit Worten, was er tut, nicht nur mit einem
Symbol. */
const auf = el('a', 'ev-buehne__ganz');
auf.href = '/workspace/api/dateien/' + bild.id + '/bild';
auf.target = '_blank';
auf.rel = 'noopener';
auf.append(el('span', 'ev-buehne__ganz-zeichen'));
auf.append(el('span', 'ev-buehne__ganz-wort', 'Banner ganz'));
auf.title = 'Zeigt das Banner in voller Größe in einem neuen Fenster';
b.append(auf);
}
b.append(el('div', 'ev-buehne__schleier'));
const inhalt = el('div', 'ev-buehne__inhalt');
kopf.classList.add('ev-buehne__kopf');
inhalt.append(kopf);
/* DIE ZEITZEILE -- einmal, nicht dreimal.
"noch 13 Tage" steht VOR dem Datum: Es ist die Antwort, das
Datum ist der Beleg dazu. Und ein Zeitraum gehoert als Spanne
gelesen, nicht als zwei Einzeldaten -- deshalb ein Gedankenstrich
und kein zweites Feld. */
const zeit = el('p', 'ev-zeit');
const noch = eventNoch(e);
if (noch) {
const m = el('span', 'ev-noch', noch.wort);
m.dataset.stand = stand.schluessel;
if (noch.dringend) m.dataset.dringend = 'ja';
zeit.append(m);
}
const von = datum(e.datum);
const bis = e.event_ende && e.event_ende !== e.datum ? datum(e.event_ende) : null;
zeit.append(el('span', 'ev-spanne', bis ? von + ' – ' + bis : von));
inhalt.append(zeit);
/* DER ZEITBALKEN. "Wie viel ist herum" sieht man an einer Laenge
sofort; als Zahl muesste man sie lesen und umrechnen. Fuer
Vorleseprogramme steht derselbe Inhalt als Satz da -- ein Balken
ohne Beschriftung ist fuer sie gar nicht vorhanden. */
const anteil = eventFortschritt(e);
if (anteil !== null && stand.schluessel !== 'kommt') {
const bal = el('div', 'ev-zeitbalken');
const f = el('div', 'ev-zeitbalken__fuellung');
f.style.width = Math.round(anteil * 100) + '%';
bal.append(f);
bal.setAttribute('role', 'img');
bal.setAttribute('aria-label', noch
? 'Zeitraum: ' + noch.wort
: Math.round(anteil * 100) + ' Prozent des Zeitraums vorbei');
inhalt.append(bal);
}
b.append(inhalt);
return b;
}
/* =====================================================================
DIE AUFGABEN ZUM ABHAKEN (03.10.2026)
Bisher stand hier ein Absatz mit white-space: pre-line -- also
genau das, was DogFather getippt hatte, Zeichen fuer Zeichen. Im
echten Oktober-Event sah das so aus:
.Jeden Tag Live gehen
.22-Live-Stunden erreichen
. 33k Diamanten erreichen
Einmal mit Leerzeichen, zweimal ohne. Das ist keine Schlamperei,
sondern die unvermeidliche Folge davon, dass die Aufzaehlungs-
zeichen von Hand getippt werden. Jetzt setzt die Liste sie selbst
(siehe punkteAusText im Server), und die Einrueckung stimmt
zwangslaeufig.
JEDER HAKT FUER SICH AB. Der Haken ist eine Selbstauskunft: "diese
Bedingung habe ich erfuellt". Niemand kann ihn bei jemand anderem
setzen, auch die Leitung nicht -- sonst waere es eine Bewertung.
DIE ZAHL KOMMT VOM SERVER, auch nach dem Klicken. Im Browser
weiterzuzaehlen ("war 1, jetzt 2") geht schief, sobald zwei
Fenster offen sind oder eine Aufgabe inzwischen umgeschrieben
wurde. Angezeigt wird sie trotzdem sofort -- bei einem Haken ist
eine halbe Sekunde Wartezeit der Unterschied zwischen "hat
geklappt" und "hab ich das jetzt gedrueckt?". Antwortet der Server
anders, gilt seine Zahl. Dieselbe Ueberlegung steht schon beim
Knopf "Will ich auch".
EIN VORBEIGELAUFENES EVENT WIRD NICHT MEHR ABGEHAKT. Der Haken
waere eine Behauptung ueber etwas, das nicht mehr stattfindet --
und die Liste bleibt trotzdem lesbar, damit man nachschlagen kann,
worum es ging. */
function eventAufgaben(e, stand) {
const punkte = Array.isArray(e.event_punkte) ? e.event_punkte : [];
if (!punkte.length) return null;
const block = el('section', 'ev-block ev-block--aufgaben');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', 'Aufgaben'));
const offen = stand.schluessel !== 'vorbei';
let geschafft = punkte.filter((p) => p.meiner).length;
const zaehler = el('span', 'ev-zaehler');
const zahlMalen = () => {
zaehler.textContent = geschafft + ' von ' + punkte.length;
const voll = punkte.length > 0 && geschafft === punkte.length;
zaehler.dataset.voll = voll ? 'ja' : '';
block.dataset.fertig = voll ? 'ja' : '';
};
zahlMalen();
kopf.append(zaehler);
block.append(kopf);
const liste = el('ul', 'ev-liste');
for (const p of punkte) {
const zeile = el('li', 'ev-punkt');
if (!offen) {
/* Ohne Knopf, aber mit demselben Zeichen davor: Die Liste soll
gleich aussehen, nur eben nicht mehr bedienbar. */
zeile.dataset.abgehakt = p.meiner ? 'ja' : '';
zeile.append(el('span', 'ev-punkt__kaestchen'));
zeile.append(el('span', 'ev-punkt__text', p.text));
liste.append(zeile);
continue;
}
const knopf = el('button', 'ev-punkt__knopf');
knopf.type = 'button';
let meiner = !!p.meiner;
const malen = () => {
knopf.setAttribute('aria-pressed', String(meiner));
zeile.dataset.abgehakt = meiner ? 'ja' : '';
};
knopf.append(el('span', 'ev-punkt__kaestchen'));
knopf.append(el('span', 'ev-punkt__text', p.text));
malen();
knopf.addEventListener('click', async () => {
knopf.disabled = true;
meiner = !meiner;
geschafft += meiner ? 1 : -1;
malen(); zahlMalen();
try {
const a = await hole('/workspace/api/eintrag/' + e.id + '/punkt/' + p.schluessel,
meiner
? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }
: { method: 'DELETE' });
if (a.ok) {
const j = await a.json();
geschafft = j.geschafft;
const frisch = (j.punkte || []).find((x) => x.schluessel === p.schluessel);
if (frisch) meiner = !!frisch.meiner;
malen(); zahlMalen();
} else {
/* ZURUECKDREHEN UND SAGEN, WORAN ES LAG. Ein Haken, der
aussieht wie gesetzt und keiner ist, ist schlimmer als
ein Klick, der sichtbar nicht geklappt hat. */
meiner = !meiner;
geschafft += meiner ? 1 : -1;
malen(); zahlMalen();
melde(sagWas((await a.json().catch(() => ({}))).fehler,
'Das Abhaken hat nicht geklappt.'));
}
} catch {
/* umgeleitet -- holen.js schickt bei 401 zur Anmeldung */
} finally { knopf.disabled = false; }
});
zeile.append(knopf);
liste.append(zeile);
}
block.append(liste);
/* WER MACHT MIT? Nur fuer die Leitung (der Server schickt das Feld
sonst gar nicht), und nur, wenn ueberhaupt jemand angefangen hat.
Eine Ueberschrift "Dabei" ueber einer leeren Flaeche sieht aus,
als sei die Abfrage kaputt. */
if (Array.isArray(e.event_stand) && e.event_stand.length) {
const wer = el('div', 'ev-mitmacher');
wer.append(el('span', 'ev-mitmacher__schild', 'Dabei'));
for (const m of e.event_stand) {
const x = el('span', 'ev-mitmacher__person',
(m.name || 'jemand') + ' · ' + m.geschafft + '/' + punkte.length);
if (m.geschafft >= punkte.length) x.dataset.voll = 'ja';
wer.append(x);
}
block.append(wer);
}
return block;
}
/** Die Regeln -- dieselbe Liste, aber ohne Haken.
*
* BEWUSST ANDERS ALS DIE AUFGABEN. Vorher waren beide derselbe graue
* Kasten mit derselben Ueberschriftzeile; nebeneinander sahen "was
* ich tun soll" und "was dabei gilt" gleich wichtig aus. Eine Regel
* ist aber kein Schritt: Man hakt sie nicht ab, man haelt sie ein. */
function eventRegeln(e) {
const punkte = Array.isArray(e.event_regelpunkte) ? e.event_regelpunkte : [];
if (!punkte.length) return null;
const block = el('section', 'ev-block ev-block--regeln');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', 'Regeln'));
block.append(kopf);
const liste = el('ul', 'ev-liste ev-liste--still');
for (const p of punkte) {
const zeile = el('li', 'ev-punkt ev-punkt--regel');
zeile.append(el('span', 'ev-punkt__zeichen'));
zeile.append(el('span', 'ev-punkt__text', p.text));
liste.append(zeile);
}
block.append(liste);
return block;
}
/** Der dritte Ausgang fuer die Event-Bloecke.
*
* Kommt vom Server keine Punkteliste (alte Fassung im Netz, Feld
* leer geblieben, Abfrage gescheitert), stand hier sonst GAR NICHTS
* -- die Aufgaben eines laufenden Wettbewerbs waeren wortlos
* verschwunden. Das ist der schlimmere Ausgang: Eine schlichte
* Liste ist ein kleiner Schoenheitsfehler, eine fehlende Bedingung
* ein inhaltlicher Verlust.
*
* Es ist bewusst dieselbe Form wie frueher (Absatz mit erhaltenen
* Zeilenumbruechen), nur im neuen Rahmen. */
function eventRueckfall(name, text) {
const block = el('section', 'ev-block ev-block--roh');
const kopf = el('div', 'ev-block__kopf');
kopf.append(el('h4', 'ev-block__schild', name));
block.append(kopf);
block.append(el('p', 'ev-rohtext', text));
return block;
}
function karte(e) { function karte(e) {
const k = el('article', 'eintrag-karte'); const k = el('article', 'eintrag-karte');
k.dataset.dringlich = e.dringlichkeit; k.dataset.dringlich = e.dringlichkeit;
k.dataset.status = e.status; k.dataset.status = e.status;
/* IST DAS EIN EVENT? Dann sieht diese Karte anders aus -- Buehne
statt Bildstreifen, Listen statt Textbloecke, der Zeitraum einmal
statt dreimal. Die Frage wird HIER gestellt und nicht erst unten
bei den Bloecken: Davon haengt auch ab, ob das Bild oben als
Streifen erscheint (bei einem Event ist es der Hintergrund des
Kopfes) -- und diese Entscheidung faellt weiter oben als die
bisherige Zeile `const stand = ...`. */
const evStand = istEventArt(e.art) ? eventStand(e) : null;
if (evStand) k.dataset.eventkarte = 'ja';
/* DIE ART GEHOERT AN DIE KARTE (22.09.2026). /* DIE ART GEHOERT AN DIE KARTE (22.09.2026).
Am Anschlagbrett traegt sie die Farbe: Ansage, Neue Regel, Am Anschlagbrett traegt sie die Farbe: Ansage, Neue Regel,
@@ -701,7 +1089,11 @@
nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein
echter Verweis mit Beschriftung -- das kann die Tastatur, ohne echter Verweis mit Beschriftung -- das kann die Tastatur, ohne
eine Zeile JavaScript. */ eine Zeile JavaScript. */
if (Array.isArray(e.bilder) && e.bilder.length) { /* BEI EINEM EVENT NICHT HIER: Das erste Bild wird zur Buehne
hinter dem Titel (siehe eventBuehne). Ein Streifen DARUEBER und
dasselbe Bild nochmal dahinter waere die Doppelung, die diese
Karte gerade verloren hat. */
if (!evStand && Array.isArray(e.bilder) && e.bilder.length) {
const bg = el('div', 'bildstreifen'); const bg = el('div', 'bildstreifen');
if (e.bilder.length > 1) bg.dataset.mehrere = 'ja'; if (e.bilder.length > 1) bg.dataset.mehrere = 'ja';
for (const b of e.bilder) { for (const b of e.bilder) {
@@ -745,7 +1137,12 @@
k.append(bg); k.append(bg);
} }
k.append(kopf); /* Beim Event wandert derselbe Kopf auf die Buehne -- mit allem,
was darin steckt (Etikett, Titel, "nur DogFather", Bewertung).
Ihn dort neu zu bauen hiesse, jede dieser Sonderregeln ein
zweites Mal zu pflegen. */
if (evStand) k.append(eventBuehne(e, evStand, kopf));
else k.append(kopf);
/* ---- DAS VIDEO (17.09.2026), Stufe A des Video-Plans ---------- /* ---- DAS VIDEO (17.09.2026), Stufe A des Video-Plans ----------
Der Knopf ist die Antwort auf "und dann liken": Er fuehrt Der Knopf ist die Antwort auf "und dann liken": Er fuehrt
@@ -874,18 +1271,12 @@
-- da, wo bei allen anderen Karten das Datum steht --, liest man -- da, wo bei allen anderen Karten das Datum steht --, liest man
erst die Aufgaben und danach, dass es seit einer Woche vorbei erst die Aufgaben und danach, dass es seit einer Woche vorbei
ist. */ ist. */
const stand = istEventArt(e.art) ? eventStand(e) : null; const stand = evStand;
if (stand) { /* Der Zustand bleibt als Merkmal an der Karte -- daran haengt im
k.dataset.event = stand.schluessel; CSS, wie ein vorbeigelaufenes Event zuruecktritt. Die ZEILE, die
const zeile = el('p', 'event-zeitraum'); hier frueher stand ("laeuft bis 16.10.2026" plus Spanne), ist
zeile.dataset.stand = stand.schluessel; weg: Sie steht jetzt in der Buehne, und zwar einmal. */
zeile.append(el('span', 'event-zeitraum__marke', stand.text)); if (stand) k.dataset.event = stand.schluessel;
zeile.append(el('span', 'event-zeitraum__spanne',
e.event_ende && e.event_ende !== e.datum
? `${datum(e.datum)} – ${datum(e.event_ende)}`
: datum(e.datum)));
k.append(zeile);
}
if (e.text) { if (e.text) {
const t = el('p', 'eintrag-text'); const t = el('p', 'eintrag-text');
@@ -894,15 +1285,35 @@
} }
if (stand) { if (stand) {
for (const [feld, name] of [['event_aufgaben', 'Aufgaben'], ['event_regeln', 'Regeln']]) { /* DIE ZERLEGUNG MACHT DER SERVER (event_punkte /
if (!e[feld]) continue; event_regelpunkte). Sie hier zu wiederholen hiesse, die
const block = el('div', 'event-block'); Rechenvorschrift fuer den Schluessel eines Punktes an zwei
block.append(el('h4', 'event-block__schild', name)); Stellen zu haben -- und ein Haken, der nach einem Umbau am
/* Zeilenumbrueche bleiben stehen (white-space: pre-line im CSS). falschen Punkt haengt, faellt niemandem auf.
Eine Aufzaehlung, die zu einem Absatz zusammenlaeuft, ist
genau das, was diese beiden Felder verhindern sollen. */ FAELLT DIE AUSKUNFT AUS (alte Serverfassung, leeres Feld),
block.append(el('p', 'event-block__text', e[feld])); bleibt der Textblock als Rueckfall stehen. Lieber eine
k.append(block); schlichte Liste als eine Karte, auf der die Aufgaben fehlen. */
const auf = eventAufgaben(e, stand)
|| (e.event_aufgaben ? eventRueckfall('Aufgaben', e.event_aufgaben) : null);
const reg = eventRegeln(e)
|| (e.event_regeln ? eventRueckfall('Regeln', e.event_regeln) : null);
/* BEIDE IN EINE REIHE, nicht untereinander an die Karte.
Auf 1280 px ist eine Karte rund 1160 px breit. "30
Live-Stunden" ueber diese Breite zu ziehen und darunter noch
einmal "Nur eigene Konten" verschenkt zwei Drittel der Flaeche
und macht die Karte unnoetig lang.
Wie viele Spalten daraus werden, entscheidet das CSS -- und
zwar durch MESSEN (auto-fit mit Mindestbreite), nicht durch
eine Fensterbreite in einer Zahl. Eine feste Schwelle waere
beim naechsten zusaetzlichen Block wieder falsch. */
if (auf || reg) {
const reihe = el('div', 'ev-bloecke');
if (auf) reihe.append(auf);
if (reg) reihe.append(reg);
k.append(reihe);
} }
} }
@@ -922,7 +1333,12 @@
ES IST EIN LINK, keine Marke. Wer liest „steht im Kalender", ES IST EIN LINK, keine Marke. Wer liest „steht im Kalender",
will als Naechstes nachsehen, was noch an dem Tag ist. */ will als Naechstes nachsehen, was noch an dem Tag ist. */
if (e.termin) { /* BEIM EVENT NICHT: Dort steht der Zeitraum schon in der Buehne.
"Im Kalender am 03.10.2026" waere die DRITTE Zeile mit demselben
Datum gewesen -- gemessen am echten Oktober-Event standen dort
"laeuft bis 16.10.", "03.10. - 16.10." und "Im Kalender am
03.10." untereinander. */
if (e.termin && !evStand) {
/* LINK NUR, WENN DER KALENDER FUER MICH OFFEN IST (19.09.2026). /* LINK NUR, WENN DER KALENDER FUER MICH OFFEN IST (19.09.2026).
Fuer die Community ist er es nicht -- `kalender.html` steht in Fuer die Community ist er es nicht -- `kalender.html` steht in
@@ -1459,7 +1875,7 @@
return; return;
} }
await laden(); await laden();
})); }, 'schritt--gefahr'));
} }
fuss.append(knoepfe); fuss.append(knoepfe);
k.append(fuss); k.append(fuss);
@@ -2775,6 +3191,346 @@
Oeffnen zu schalten waere der bequeme Fehler: Man oeffnet mit Oeffnen zu schalten waere der bequeme Fehler: Man oeffnet mit
"Schulung", waehlt "Agentur-Events" und fuellt ein Formular aus, "Schulung", waehlt "Agentur-Events" und fuellt ein Formular aus,
das noch nach der alten Art fragt. */ das noch nach der alten Art fragt. */
/* =====================================================================
DAS EVENT-FORMULAR (03.10.2026)
Filipe: "und auch wenn man die eintraegt und so soll viel mehr
perfektioniert und uebersichtlicher sein."
WORAN ES LAG, SIEHT MAN AM ERGEBNIS. Im echten Oktober-Event stand
auf der Karte:
.Jeden Tag Live gehen
.22-Live-Stunden erreichen
. 33k Diamanten erreichen
Einmal mit Leerzeichen hinter dem Punkt, zweimal ohne. Das ist
keine Unachtsamkeit, sondern die zwangslaeufige Folge eines leeren
Textfeldes: Wer eine Aufzaehlung tippt, setzt die Zeichen selbst --
und dann sehen drei Zeilen auf drei Arten aus.
DIE LOESUNG IST NICHT, DAS ERGEBNIS ZU PUTZEN, sondern die Eingabe
zu aendern: EINE ZEILE, EIN FELD. Das Aufzaehlungszeichen setzt
das Formular, nicht der Mensch.
DAS TEXTFELD BLEIBT TROTZDEM DIE WAHRHEIT. Abgeschickt wird
weiterhin `event_aufgaben` als Text -- der Server bleibt
unveraendert, und wer lieber tippt (oder etwas aus einer Nachricht
einfuegt), schaltet mit einem Knopf zurueck. Ein Editor, der den
einzigen Weg darstellt, ist eine Sackgasse, sobald er einen Fall
nicht kann.
EINGEFUEGTER MEHRZEILIGER TEXT WIRD AUFGETEILT. Der haeufigste
Weg, wie so eine Liste entsteht, ist Kopieren aus einer Nachricht.
Landete das alles in einem Feld, waere der Editor genau dort im
Weg, wo er helfen soll.
===================================================================== */
/** Blendet die Textgestaltungs-Leiste (F K U S) mit ihrem Feld um.
*
* SIE GEHOERT ZUM TEXTFELD, NICHT ZUM FORMULAR. Solange der
* Zeilen-Editor dran ist, ist das Feld verborgen -- die Leiste
* stand aber weiter da und schwebte unter einer Liste, zu der sie
* nicht gehoert. Beim ersten Lauf sah man unter "+ Punkt" ein
* "F K U S", das nichts bewirkte.
*
* SIE WIRD JEDES MAL NEU GESUCHT und nicht beim Aufbau gemerkt:
* textform.js haengt sie NACH dem Formularaufbau an (sie haengt an
* `laden()`), und etwas zu merken, was es noch nicht gibt, ergibt
* null. */
function textformUmblenden(feld, zeigen) {
const l = feld?.parentElement?.querySelector('.textform');
if (l) l.hidden = !zeigen;
}
/** Baut den Zeilen-Editor vor ein Textfeld.
* feldId: die Nummer des <textarea>, das die Wahrheit bleibt. */
function zeilenEditor(feldId, wort, beispiel) {
const feld = $(feldId);
if (!feld || feld.dataset.editor === 'ja') return;
feld.dataset.editor = 'ja';
const rahmen = el('div', 'zeileneditor');
const liste = el('div', 'zeileneditor__liste');
rahmen.append(liste);
/* Aus dem Feld in den Editor -- beim Oeffnen und beim Zurueck-
schalten. Zerlegt wird nach derselben Regel wie im Server
(Zeilenumbruch, fuehrendes Aufzaehlungszeichen weg); dass beide
dasselbe tun, weist pruef-eventkarte.mjs nach. */
const auseinander = (text) => String(text || '')
.split(/\r?\n/)
.map((z) => z.replace(/^[\s]*[-–—•·*▪◦‣>+]+[\s]*/u, '').trim())
.filter(Boolean);
/* ... und zurueck. Das Zeichen setzt das Formular, einheitlich. */
const zusammen = () => [...liste.querySelectorAll('.zeileneditor__feld')]
.map((f) => f.value.trim())
.filter(Boolean)
.map((t) => '· ' + t)
.join('\n');
const schreiben = () => {
feld.value = zusammen();
/* Das Ereignis von Hand ausloesen: Die Vorschau haengt daran,
und ein Wert, der per Skript gesetzt wird, loest `input` nicht
von selbst aus. */
feld.dispatchEvent(new Event('input', { bubbles: true }));
};
function zeileBauen(wert, fokus) {
const z = el('div', 'zeileneditor__zeile');
const punkt = el('span', 'zeileneditor__zeichen');
punkt.setAttribute('aria-hidden', 'true');
const f = el('input', 'zeileneditor__feld');
f.type = 'text';
f.value = wert || '';
f.maxLength = 300;
f.setAttribute('aria-label', wort + ': ein Punkt');
/* DER PLATZHALTER GEHOERT ZUM FELD. "z. B. 22 Live-Stunden
erreichen" stand im ersten Lauf auch unter REGELN -- und ein
Beispiel, das zur Ueberschrift darueber nicht passt, ist eine
Fehlanleitung, keine Hilfe. */
f.placeholder = beispiel;
f.addEventListener('input', schreiben);
/* EINFUEGEN AUS EINER NACHRICHT. Der haeufigste Weg, wie so eine
Liste entsteht. Ohne das landete der ganze Block in einem
Feld -- und der Editor waere genau dort hinderlich, wo er
helfen soll. */
f.addEventListener('paste', (ev) => {
const roh = ev.clipboardData?.getData('text') || '';
if (!/\r?\n/.test(roh)) return;
ev.preventDefault();
const teile = auseinander(roh);
if (!teile.length) return;
f.value = teile[0];
let davor = z;
for (const t of teile.slice(1)) {
const neu = zeileBauen(t);
davor.insertAdjacentElement('afterend', neu);
davor = neu;
}
schreiben();
});
f.addEventListener('keydown', (ev) => {
/* ENTER MACHT EINE NEUE ZEILE -- so wie in jeder Liste, die
man kennt. Ohne das muesste man nach jedem Punkt zur Maus
greifen, und bei fuenf Punkten ist das fuenfmal. */
if (ev.key === 'Enter') {
ev.preventDefault();
const neu = zeileBauen('', true);
z.insertAdjacentElement('afterend', neu);
schreiben();
return;
}
/* RUECKTASTE IM LEEREN FELD nimmt die Zeile weg und setzt den
Schreibzeiger ans Ende der vorigen -- dieselbe Erwartung wie
in einem Textfeld. Die letzte Zeile bleibt stehen: ein
Editor ohne ein einziges Feld hat keinen Weg zurueck. */
if (ev.key === 'Backspace' && !f.value) {
const alle = [...liste.querySelectorAll('.zeileneditor__zeile')];
if (alle.length <= 1) return;
ev.preventDefault();
const stelle = alle.indexOf(z);
const ziel = alle[stelle - 1] || alle[stelle + 1];
z.remove();
schreiben();
const zf = ziel?.querySelector('.zeileneditor__feld');
if (zf) { zf.focus(); zf.setSelectionRange(zf.value.length, zf.value.length); }
}
});
const weg = el('button', 'zeileneditor__weg');
weg.type = 'button';
weg.setAttribute('aria-label', 'Diesen Punkt entfernen');
weg.title = 'Diesen Punkt entfernen';
weg.addEventListener('click', () => {
const alle = [...liste.querySelectorAll('.zeileneditor__zeile')];
if (alle.length <= 1) { f.value = ''; schreiben(); f.focus(); return; }
z.remove();
schreiben();
});
z.append(punkt, f, weg);
if (fokus) setTimeout(() => f.focus(), 0);
return z;
}
const fuellen = () => {
liste.textContent = '';
const teile = auseinander(feld.value);
if (!teile.length) liste.append(zeileBauen(''));
else for (const t of teile) liste.append(zeileBauen(t));
};
fuellen();
const fuss = el('div', 'zeileneditor__fuss');
const mehr = el('button', 'zeileneditor__mehr', '+ Punkt');
mehr.type = 'button';
mehr.addEventListener('click', () => {
liste.append(zeileBauen('', true));
schreiben();
});
fuss.append(mehr);
/* DER WEG ZURUECK ZUM TEXTFELD. Er steht leise daneben, nicht als
zweiter Hauptknopf: Der Normalfall ist die Liste. */
const umschalten = el('button', 'zeileneditor__wechsel', 'als Text bearbeiten');
umschalten.type = 'button';
umschalten.addEventListener('click', () => {
const alsText = rahmen.hidden !== true;
if (alsText) {
rahmen.hidden = true;
feld.hidden = false;
textformUmblenden(feld, true);
umschalten.textContent = 'als Liste bearbeiten';
feld.focus();
} else {
/* Zurueck in die Liste: neu einlesen, damit auch das ankommt,
was im Textfeld getippt wurde. */
fuellen();
rahmen.hidden = false;
feld.hidden = true;
textformUmblenden(feld, false);
umschalten.textContent = 'als Text bearbeiten';
}
});
fuss.append(umschalten);
rahmen.append(fuss);
feld.hidden = true;
textformUmblenden(feld, false);
feld.insertAdjacentElement('beforebegin', rahmen);
/* Der Umschalter gehoert NEBEN beides und nicht in den Rahmen --
sonst verschwindet er mit, sobald man zum Textfeld wechselt. */
rahmen.insertAdjacentElement('afterend', fuss);
/* Wird das Feld von aussen gesetzt (Vorlage uebernehmen, Formular
zuruecksetzen), muss die Liste nachziehen. */
feld.addEventListener('frischfuellen', fuellen);
}
/* =====================================================================
DIE VORSCHAU (03.10.2026)
Sie zeigt die BUEHNE, und zwar mit genau der Funktion, die sie
spaeter auch auf dem Brett baut (eventBuehne). Eine nachgebaute
Vorschau waere eine zweite Fassung derselben Sache -- und die
zeigt irgendwann etwas, das hinterher anders aussieht. Dann ist
sie schlimmer als keine.
SIE ERSCHEINT ERST, WENN ES ETWAS ZU ZEIGEN GIBT. Eine leere
Vorschau ueber einem leeren Formular ist nur ein weiterer Kasten.
===================================================================== */
function vorschauZeichnen() {
const ziel = $('ev-vorschau');
if (!ziel) return;
const event = istEventArt($('f-art').value);
const titel = ($('f-titel')?.value || '').trim();
if (!event || !titel) { ziel.hidden = true; ziel.textContent = ''; return; }
const heute = window.heuteLokal ? window.heuteLokal()
: new Date().toISOString().slice(0, 10);
const e = {
id: 0,
titel,
art: EVENT_ART,
datum: $('f-datum')?.value || heute,
event_ende: $('f-ende')?.value || null,
bilder: [],
};
const stand = eventStand(e);
if (!stand) { ziel.hidden = true; ziel.textContent = ''; return; }
ziel.hidden = false;
ziel.textContent = '';
ziel.append(el('p', 'ev-vorschau__schild', 'So sieht es nachher aus'));
const karte = el('article', 'eintrag-karte');
karte.dataset.eventkarte = 'ja';
karte.dataset.event = stand.schluessel;
karte.dataset.status = 'offen';
/* Derselbe Kopf wie auf dem Brett: Etikett und Titel. */
const kopf = el('div', 'eintrag-kopf');
/* DAS ETIKETT KOMMT VOM SERVER, OHNE RUECKFALL (03.10.2026).
Hier stand `|| 'Agentur-Events'`. Das ist genau das, was
pruef-treff.mjs verbietet -- und zwar aus einem handfesten
Grund: Wie ein Brett heisst, haengt am HAUS. Dieselbe Kennung
heisst auf crew. "Team Dogi" und auf workspace. "Agentur"; der
Server entscheidet das. Ein fest eingetragenes Wort im Browser
waere die zweite Wahrheit, die auf einer der beiden Adressen
falsch ist.
Fehlt die Angabe, steht lieber KEIN Etikett da als ein
falsches -- in der Vorschau fehlt dann eine Zierde, kein
Inhalt. */
const etikett = einstellung?.arten?.[EVENT_ART];
if (etikett) kopf.append(el('span', 'marke-art', etikett));
kopf.append(el('h3', 'eintrag-titel', titel));
karte.append(eventBuehne(e, stand, kopf));
ziel.append(karte);
}
/** Wie lange laeuft das Event? Steht unter dem Zeitraum.
*
* NICHT NUR ZIERDE: "02.10. bis 15.10." liest man als Spanne, aber
* ob das zwei Wochen sind, rechnet man im Kopf nach. Und ein Ende
* VOR dem Anfang ist ein Tippfehler, den man sonst erst auf der
* Karte sieht -- dort steht dann "vorbei seit 3 Tagen" an einem
* Event, das gerade erst angelegt wurde. */
function dauerZeigen() {
const ziel = $('hinweis-dauer');
if (!ziel) return;
const event = istEventArt($('f-art').value);
const von = $('f-datum')?.value || '';
const bis = $('f-ende')?.value || '';
if (!event || !von) { ziel.textContent = ''; delete ziel.dataset.warnung; return; }
if (!bis) {
ziel.textContent = 'Ohne Ende gilt der eine Tag.';
delete ziel.dataset.warnung;
return;
}
const a = new Date(von + 'T00:00:00').getTime();
const b = new Date(bis + 'T00:00:00').getTime();
if (Number.isNaN(a) || Number.isNaN(b)) { ziel.textContent = ''; return; }
if (b < a) {
ziel.textContent = 'Das Ende liegt vor dem Anfang.';
ziel.dataset.warnung = 'ja';
return;
}
delete ziel.dataset.warnung;
/* EINSCHLIESSLICH BEIDER TAGE: Ein Event vom 2. bis zum 15. laeuft
14 Tage, nicht 13 -- der letzte Tag zaehlt mit, und genau so
rechnet auch eventNoch auf der Karte. */
const tage = Math.round((b - a) / 86400000) + 1;
ziel.textContent = tage === 1 ? 'Ein Tag.' : `Läuft ${tage} Tage.`;
}
/** Das Formular in den Zustand "frisch" bringen.
*
* AN EINER STELLE, weil es zwei Anlaesse gibt (Abbrechen und
* erfolgreiches Speichern) und beide dasselbe meinen. Vorher gab es
* zwei Fassungen, und die kuerzere liess Vorschau und Zeilen-Editor
* stehen -- der Unterschied faellt erst auf, wenn jemand zwei
* Events nacheinander anlegt. */
function formularLeeren() {
$('neu').reset();
$('neu-fehler').textContent = '';
/* `form.reset()` leert die Textfelder, aber die Zeilenfelder davor
stehen noch voll da -- und beim naechsten Tippen schrieben sie
den alten Stand zurueck. */
for (const f of ['f-aufgaben', 'f-regeln']) {
$(f)?.dispatchEvent(new Event('frischfuellen'));
}
/* Und das raeumt Dauerhinweis und Vorschau mit ab. */
formularAnpassen();
}
function formularAnpassen() { function formularAnpassen() {
const event = istEventArt($('f-art').value); const event = istEventArt($('f-art').value);
@@ -2791,6 +3547,18 @@
$('feld-ende').hidden = !event; $('feld-ende').hidden = !event;
$('feld-aufgaben').hidden = !event; $('feld-aufgaben').hidden = !event;
$('feld-regeln').hidden = !event; $('feld-regeln').hidden = !event;
/* DER ZEILEN-EDITOR WIRD ERST GEBAUT, WENN ER GEBRAUCHT WIRD --
und nur einmal (zeilenEditor merkt sich das am Feld). Ihn beim
Laden der Seite fuer jedes Brett aufzubauen hiesse, zwei
Eingabehilfen vorzuhalten, die auf fuenf von sechs Brettern nie
sichtbar werden. */
if (event) {
zeilenEditor('f-aufgaben', 'Aufgaben', 'z. B. 22 Live-Stunden erreichen');
zeilenEditor('f-regeln', 'Regeln', 'z. B. Nur Streams auf dem eigenen Konto');
}
dauerZeigen();
vorschauZeichnen();
if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event; if (einstellung?.dringlichkeit) $('feld-dringlich').hidden = event;
if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event; if (einstellung?.vertraulich) $('feld-vertraulich').hidden = event;
@@ -2845,13 +3613,19 @@
umschalten(true); umschalten(true);
}); });
$('neu-abbrechen').addEventListener('click', () => { $('neu-abbrechen').addEventListener('click', () => {
$('neu').reset(); $('neu-fehler').textContent = ''; formularAnpassen(); umschalten(false); formularLeeren(); umschalten(false);
}); });
/* "change" und nicht "input": Die Auswahl wird ueber wahl.js zu einer /* "change" und nicht "input": Die Auswahl wird ueber wahl.js zu einer
Knopfliste umgebaut, und die meldet ihre Wahl als change-Ereignis Knopfliste umgebaut, und die meldet ihre Wahl als change-Ereignis
am urspruenglichen <select> -- ohne dieses Ereignis merkte das am urspruenglichen <select> -- ohne dieses Ereignis merkte das
Formular vom Artwechsel nichts. */ Formular vom Artwechsel nichts. */
$('f-art').addEventListener('change', formularAnpassen); $('f-art').addEventListener('change', formularAnpassen);
/* Dauer und Vorschau haengen an vier Feldern. Ein einziger Zuhoerer
am Formular statt vier einzelnen: Kommt ein fuenftes Feld dazu,
ist es automatisch dabei -- eine Liste von Feldnamen waere die,
die beim naechsten Umbau nicht nachgezogen wird. */
$('neu')?.addEventListener('input', () => { dauerZeigen(); vorschauZeichnen(); });
$('neu')?.addEventListener('change', () => { dauerZeigen(); vorschauZeichnen(); });
/* DER HINWEIS ZIEHT MIT. Ohne diese beiden Zeilen stuende er auf dem /* DER HINWEIS ZIEHT MIT. Ohne diese beiden Zeilen stuende er auf dem
Stand vom Oeffnen des Formulars -- also dauerhaft auf „Heute", auch Stand vom Oeffnen des Formulars -- also dauerhaft auf „Heute", auch
nachdem jemand den 24. eingetragen hat. Ein Hinweis, der beim Tippen nachdem jemand den 24. eingetragen hat. Ein Hinweis, der beim Tippen
@@ -2915,7 +3689,22 @@
$('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.'); $('neu-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
return; return;
} }
$('neu').reset(); umschalten(false); /* DIESELBE FUNKTION WIE BEIM ABBRECHEN (03.10.2026).
Hier stand `$('neu').reset()` allein -- also eine ZWEITE
Fassung des Zuruecksetzens, die weniger tat als die beim
Abbrechen. Gemessen hat das die Pruefung: Nach dem Speichern
blieb die Vorschau mit dem gerade angelegten Event stehen
(verborgen: false, 81 Zeichen Inhalt), und beim naechsten
"Neuer Eintrag" standen die Aufgaben des vorigen Events noch
im Editor -- man haette sie versehentlich ein zweites Mal
eingetragen.
`form.reset()` allein kann das gar nicht leisten: Es setzt
Formularfelder auf ihre Vorgabe zurueck und weiss nichts von
Zeilen-Editoren, Hinweisen oder einer Vorschau, die daneben
stehen. */
formularLeeren(); umschalten(false);
await laden(); await laden();
} catch { /* umgeleitet */ } finally { knopf.disabled = false; } } catch { /* umgeleitet */ } finally { knopf.disabled = false; }
}); });
@@ -3233,7 +4022,18 @@
if (window.Textform) { if (window.Textform) {
for (const id of ['f-text', 'f-aufgaben', 'f-regeln']) { for (const id of ['f-text', 'f-aufgaben', 'f-regeln']) {
const feld = document.getElementById(id); const feld = document.getElementById(id);
if (feld) window.Textform.leiste(feld); if (!feld) continue;
window.Textform.leiste(feld);
/* ERST DANACH VERSTECKEN. Der Zeilen-Editor steht bei den
Event-Feldern schon (er entsteht in formularAnpassen, und
das laeuft innerhalb von laden()) -- die Leiste kommt also
NACH ihm und waere sonst als einziges Stueck des Textfeldes
sichtbar geblieben. Das war im ersten Bildschirmfoto das
"F K U S" unter der Punkteliste. */
if (feld.dataset.editor === 'ja' && feld.hidden) {
const l = feld.parentElement?.querySelector('.textform');
if (l) l.hidden = true;
}
} }
} }
})(); })();
+226 -5
View File
@@ -626,6 +626,11 @@
let raeume = []; let raeume = [];
let offen = null; // der gerade angesehene Raum let offen = null; // der gerade angesehene Raum
let nachrichten = []; let nachrichten = [];
/* WELCHE NACHRICHT GERADE BEARBEITET WIRD -- 0 heisst: keine.
EINE Zahl und kein Merkmal an der Nachricht: Zwei offene Felder
gleichzeitig waeren zwei Staende desselben Textes, und beim
Speichern gewinnt der Zufall. */
let bearbeiteId = 0;
let strom = null; // die EventSource let strom = null; // die EventSource
let gewaehlt = new Set(); // im Neu-Dialog let gewaehlt = new Set(); // im Neu-Dialog
@@ -1661,9 +1666,32 @@
irgendwann von der ersten abweicht. */ irgendwann von der ersten abweicht. */
$('raum-aufloesen').hidden = !offen.darf_aufloesen; $('raum-aufloesen').hidden = !offen.darf_aufloesen;
/* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will /* ==== NUR EIN FRISCH GEOEFFNETES GESPRAECH STEHT AM ENDE =======
das Letzte sehen, nicht das Erste. */ (berichtigt 05.10.2026, gemeldet von Miss)
unten = true; neuUnten = 0;
"Wenn ich auf den Chat gehen, komme ich zuerst auf die erste
neue Nachricht, aber kurze Zeit spaeter springt er auf die
zuletzt geschrieben Nachricht."
Diese Zeile stand ausserhalb von `if (!sanft)` und lief damit
bei JEDEM Nachladen -- auch beim sanften, das staendig
passiert: Der Ereignisstrom verbindet sich, eine Nachricht
kommt, jemand heftet etwas an.
Solange die Neu-Linie steht, faellt das nicht auf: Der Zweig
darueber springt dorthin und kehrt zurueck, bevor `unten`
ueberhaupt gelesen wird. Aufgefallen ist es erst durch die
zweite Haelfte -- `wache` loescht den Merker beim ersten
FINGERTIPP auf den Verlauf. Auf einem Handy tippt man beim
Lesen fast zwangslaeufig einmal hin. Ab da ist der Weg frei,
und das naechste sanfte Nachladen findet `unten === true` vor
und reisst die Ansicht ans Ende. Genau das sind ihre "kurze
Zeit spaeter".
Ein sanftes Nachladen ist kein Oeffnen. Wo jemand steht, weiss
ab dann allein der Scroll-Horcher weiter unten -- und der misst
es, statt es anzunehmen. */
if (!sanft) { unten = true; neuUnten = 0; }
pinsZeichnen(); pinsZeichnen();
verlaufZeichnen(); verlaufZeichnen();
/* Der Entwurf von vorhin, falls einer da ist. */ /* Der Entwurf von vorhin, falls einer da ist. */
@@ -2291,7 +2319,8 @@
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
einem Foto besteht, hätte sonst einen leeren Absatz darunter einem Foto besteht, hätte sonst einen leeren Absatz darunter
-- und der nimmt Platz, den niemand erklären kann. */ -- und der nimmt Platz, den niemand erklären kann. */
if (n.text) blase.append(textAbsatz(n.text, !!n.rudel)); if (bearbeiteId === n.id) blase.append(bearbeitenFeld(n));
else if (n.text) blase.append(textAbsatz(n.text, !!n.rudel));
} }
/* ==== DAS WORT KOMMT INS ELEMENT, NICHT AN DEN KNOPF ========== /* ==== DAS WORT KOMMT INS ELEMENT, NICHT AN DEN KNOPF ==========
@@ -2335,6 +2364,17 @@
zeit.title = wann(n.erstellt, true); zeit.title = wann(n.erstellt, true);
fuss.append(zeit); fuss.append(zeit);
/* DER VERMERK STEHT AN DER NACHRICHT, nicht im Protokoll
(03.10.2026). Ein Text, der sich still aendert, nachdem
andere darauf geantwortet haben, ist ein Vertrauensproblem.
Er steht direkt hinter der Zeit, weil er zur Zeit gehoert:
beides sagt, WANN hier was passiert ist. */
if (n.bearbeitet_am) {
const v = el('span', 'chat-nachricht__bearbeitet', 'bearbeitet');
v.title = `bearbeitet ${wann(n.bearbeitet_am, true)}`;
fuss.append(v);
}
/* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer /* Antworten geht auf JEDE Nachricht, auch die eigene -- in einer
Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine
zurueckgenommene nicht: Das Zitat waere leer. */ zurueckgenommene nicht: Das Zitat waere leer. */
@@ -2373,6 +2413,27 @@
das kein gewöhnlicher Handgriff ist. Die Schranke steht das kein gewöhnlicher Handgriff ist. Die Schranke steht
ohnehin im Server; hier geht es darum, sie nicht aus Versehen ohnehin im Server; hier geht es darum, sie nicht aus Versehen
zu benutzen. */ zu benutzen. */
/* BEARBEITEN -- NUR DIE EIGENE (03.10.2026).
Filipe: "jeder soll seine eigene nachricht bearbeiten
koennen."
`n.selbst` OHNE die Ausnahme fuer DogFather und die rechte
Hand, die es beim Loeschen gibt. Eine fremde Nachricht zu
entfernen heisst "das soll hier nicht stehen"; sie zu AENDERN
heisst, jemandem Worte in den Mund zu legen, die unter seinem
Namen und seinem Bild stehen bleiben. Zwei verschiedene
Dinge, nicht dasselbe in groesser.
NUR WO TEXT IST: Ein reines Foto oder eine Sprachnachricht
hat nichts zu bearbeiten -- dort waere der Knopf ein Angebot,
das ins Leere fuehrt. Wer einen Begleittext hat, kann ihn
aendern; der Anhang bleibt davon unberuehrt. */
if (n.id > 0 && n.selbst && !n.zurueckgenommen && n.text && bearbeiteId !== n.id) {
const bea = wortKnopf('chat-nachricht__bearb-knopf', 'bearbeiten');
bea.addEventListener('click', () => { bearbeiteId = n.id; verlaufZeichnen(); });
fuss.append(bea);
}
if (n.id > 0 && (n.selbst || darfAllesLoeschen)) { if (n.id > 0 && (n.selbst || darfAllesLoeschen)) {
const fremd = !n.selbst; const fremd = !n.selbst;
const weg = wortKnopf('chat-nachricht__weg-knopf', const weg = wortKnopf('chat-nachricht__weg-knopf',
@@ -2802,7 +2863,31 @@
const geraet = document.createElement('audio'); const geraet = document.createElement('audio');
geraet.controls = true; geraet.controls = true;
geraet.preload = 'metadata'; geraet.preload = 'metadata';
geraet.src = a.weg; /* ==== DIE FASSUNG, DIE JEDES GERAET ABSPIELT (03.10.2026) ===
Miss im Support, Meldung #12, fuenf Runden lang: „Bei mir
laesst sich die Nachricht nicht abspielen." Bei allen
anderen ging es; alle Sprachnachrichten im Haus waren WebM.
DER SERVER LEGT SEIT HEUTE EINE ZWEITE FASSUNG DANEBEN
(AAC in MP4, siehe helfer-ffmpeg.mjs). Gibt es sie, wird
SIE genommen -- immer, nicht nur auf iPhones.
WARUM NICHT „nur wenn das Geraet das Original nicht kann":
Dann stuende hier eine Abfrage (`canPlayType`), die bei
Safari schon einmal „maybe" sagt und danach doch scheitert,
und wir haetten zwei Wege, von denen einer selten begangen
und nie geprueft wird. Ein Weg fuer alle ist einer, der
auch benutzt wird.
WAS ES KOSTET: AAC braucht fuer dieselbe Sprache etwa das
Doppelte an Bytes. Bei einer Minute sind das 700 KB statt
240 -- der Preis dafuer, dass sie bei JEDEM laeuft.
UND WENN ES SIE NICHT GIBT (ffmpeg fehlt, Umwandlung laeuft
noch, altes Format schon universell), bleibt es beim
Original. Niemand verliert etwas, was er vorher hatte. */
geraet.src = a.weg_auch || a.weg;
geraet.className = 'chat-ton__geraet'; geraet.className = 'chat-ton__geraet';
/* Ein Vorleseprogramm sagt sonst nur "Audio". */ /* Ein Vorleseprogramm sagt sonst nur "Audio". */
geraet.setAttribute('aria-label', geraet.setAttribute('aria-label',
@@ -3379,6 +3464,105 @@
+ `mit „Nochmal senden“ kannst du es gleich wiederholen.`); + `mit „Nochmal senden“ kannst du es gleich wiederholen.`);
} }
/** Das Feld, in dem eine eigene Nachricht geaendert wird.
*
* AN DER STELLE DER NACHRICHT, nicht im Schreibfeld unten. Wer
* seinen Text zum Bearbeiten unten wiederfindet, schickt ihn beim
* naechsten Enter als NEUE Nachricht ab -- und hat ihn dann
* zweimal im Raum. Hier kann das nicht passieren: Was im Feld
* steht, hat nur einen Weg, und der fuehrt auf dieselbe Nummer.
*/
function bearbeitenFeld(n) {
const kasten = el('div', 'chat-bearb');
const feld = el('textarea', 'chat-bearb__feld');
feld.value = n.text || '';
feld.rows = 1;
feld.maxLength = 4000;
feld.setAttribute('aria-label', 'Nachricht bearbeiten');
/* Mitwachsen wie das Schreibfeld unten -- bis zu einer Grenze,
sonst verdraengt eine lange Nachricht den ganzen Verlauf. */
const wachsen = () => {
feld.style.height = 'auto';
feld.style.height = Math.min(feld.scrollHeight, 200) + 'px';
};
feld.addEventListener('input', wachsen);
const fertig = () => { bearbeiteId = 0; verlaufZeichnen(); };
const senden = () => { void bearbeitenSenden(n, feld.value); };
/* DIESELBEN TASTEN WIE BEIM SCHREIBEN: Enter schickt ab,
Shift+Enter macht eine Zeile. Eine zweite Regel hier waere eine,
die man erst beim Danebengreifen merkt. Escape bricht ab -- der
Weg, den jeder ohnehin probiert. */
feld.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); senden(); }
else if (e.key === 'Escape') { e.preventDefault(); fertig(); }
});
const reihe = el('div', 'chat-bearb__reihe');
const ja = el('button', 'chat-bearb__knopf chat-bearb__knopf--ja', 'Speichern');
ja.type = 'button';
ja.addEventListener('click', senden);
const nein = el('button', 'chat-bearb__knopf', 'Abbrechen');
nein.type = 'button';
nein.addEventListener('click', fertig);
reihe.append(ja, nein);
kasten.append(feld, reihe);
/* NACH dem Einhaengen scharfstellen: Vorher hat das Feld keine
Hoehe und `focus()` rollt den Verlauf an die falsche Stelle.
Der Zeiger steht am Ende, nicht am Anfang -- man will meistens
etwas anfuegen oder das letzte Wort richten. */
setTimeout(() => {
wachsen();
feld.focus();
feld.setSelectionRange(feld.value.length, feld.value.length);
}, 0);
return kasten;
}
async function bearbeitenSenden(n, roh) {
const text = String(roh || '').trim();
if (!text) {
/* LEEREN IST LOESCHEN, und dafuer steht der Knopf daneben --
mit Rueckfrage. Hier waere es derselbe Eingriff ohne sie. */
melde('Leer geht nicht – zum Entfernen gibt es „löschen“ daneben.');
return;
}
if (text === (n.text || '')) { bearbeiteId = 0; verlaufZeichnen(); return; }
try {
const a = await hole(`/workspace/api/chat/nachrichten/${n.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!a.ok) {
const d = await a.json().catch(() => ({}));
melde(d.fehler ? `Ging nicht: ${d.fehler}` : 'Ging nicht.');
return;
}
const d = await a.json().catch(() => ({}));
/* NICHT auf das Ereignis vom Server warten. Es kommt zwar auch
bei mir an, aber erst nach dem Umweg ueber die Leitung -- und
bis dahin stuende mein alter Text noch da. Der Strom holt
spaeter dasselbe Ergebnis und schreibt dasselbe hin. */
const i = nachrichten.findIndex((x) => x.id === n.id);
if (i >= 0) {
nachrichten[i].text = text;
nachrichten[i].bearbeitet_am = d.bearbeitet_am || new Date().toISOString();
}
for (const x of nachrichten) {
if (x.antwort?.id === n.id) x.antwort = { ...x.antwort, text: text.slice(0, 140) };
}
bearbeiteId = 0;
verlaufZeichnen();
/* Die Vorschau in der Gespraechsliste zeigt denselben Satz --
sie muss mitkommen, sonst steht links der alte Text. */
raeumeLaden();
} catch { /* umgeleitet */ }
}
async function zuruecknehmen(id, fremd = false) { async function zuruecknehmen(id, fremd = false) {
if (!await frageNach({ if (!await frageNach({
titel: fremd ? 'Diese Nachricht löschen?' : 'Nachricht löschen?', titel: fremd ? 'Diese Nachricht löschen?' : 'Nachricht löschen?',
@@ -3574,6 +3758,43 @@
raeumeLaden(); raeumeLaden();
}); });
/* EINE NACHRICHT WURDE BEARBEITET (03.10.2026).
Sie bleibt an ihrer Stelle -- nur Text und Vermerk wechseln.
UND DIE RAUMLISTE WIRD DOCH NEU GEHOLT (berichtigt 03.10.2026).
Hier stand das Gegenteil, mit der Begruendung, die Liste aendere
sich ja nicht. Das war falsch, und ein Bildschirmfoto hat es
gezeigt: Links in der Gespraechsliste stand weiter "Du: Treffen
um 15 Urh", waehrend rechts schon "Treffen um 15 Uhr" stand --
derselbe Satz, zweimal verschieden, auf einem Schirm.
Richtig ist: Die REIHENFOLGE aendert sich nicht (der Server
ruehrt `letzte_am` bewusst nicht an), die VORSCHAU sehr wohl.
Ein Abruf je Bearbeitung ist dafuer kein Preis -- bearbeitet
wird selten, und die Alternative waere eine veraltete Zeile.
Wer die Nachricht gerade selbst offen im Bearbeiten-Feld hat,
behaelt sein Feld: Sein eigener Stand ist neuer als das, was
hier ankommt -- es ist ja seine eigene Aenderung, die
zurueckkommt. */
strom.addEventListener('geaendert', (e) => {
let n;
try { n = JSON.parse(e.data); } catch { return; }
const i = nachrichten.findIndex((x) => x.id === n.id);
if (i < 0) return;
nachrichten[i].text = n.text;
nachrichten[i].bearbeitet_am = n.bearbeitet_am;
/* Auch das Zitat an ANDEREN Nachrichten zieht nach -- sonst
stuende oben der alte Satz und unten der neue, und beide
behaupten, dasselbe zu sein. */
for (const x of nachrichten) {
if (x.antwort?.id === n.id) x.antwort = { ...x.antwort, text: String(n.text).slice(0, 140) };
}
verlaufZeichnen();
raeumeLaden();
});
strom.addEventListener('weg', (e) => { strom.addEventListener('weg', (e) => {
let n; let n;
try { n = JSON.parse(e.data); } catch { return; } try { n = JSON.parse(e.data); } catch { return; }
+58 -21
View File
@@ -2,6 +2,30 @@
Creator Workspace - Anmeldeseite Creator Workspace - Anmeldeseite
Kein Framework, keine externen Abhängigkeiten. Kein Framework, keine externen Abhängigkeiten.
=================================================================== */ =================================================================== */
/* WER SCHON ANGEMELDET IST, SIEHT DIE ANMELDUNG NICHT -- und das
entscheidet der SERVER, nicht diese Datei (06.10.2026).
Hier stand zuerst eine Abfrage auf `/workspace/api/ich` mit
Weiterleitung, Schleifensperre und einer Verdeckung gegen das
Aufblitzen. Sie funktionierte, war aber die schlechtere Loesung:
* Ohne Sitzung antwortet `/api/ich` mit 401. Danach stand in der
Browserkonsole bei JEDEM Aufruf der Anmeldung ein Fehler --
`pruef-neue-seiten` wurde davon zehnmal rot. Eine Meldung, die
immer kommt, liest nach dem zweiten Mal niemand mehr.
* Die Seite musste waehrend der Abfrage verdeckt werden, und
diese Verdeckung brauchte wieder eine Notfrist, damit sie bei
schlechtem Netz nicht haengen bleibt.
* Und es brauchte eine Schleifensperre mit Zeitstempel in zwei
Dateien.
Drei Hilfskonstruktionen fuer etwas, das der Server in einer Zeile
weiss: Er liest die Sitzung ohnehin bei jeder Anfrage. Die
Weiterleitung steht jetzt in workspace.js -- vor der Auslieferung
der Datei. Dadurch sieht man die Anmeldung gar nicht erst, es
gibt keinen Fehler in der Konsole, und es geht auch ohne
JavaScript. */
(() => { (() => {
'use strict'; 'use strict';
/* Zählt die Fehlversuche DIESER Sitzung -- nur im Speicher, /* Zählt die Fehlversuche DIESER Sitzung -- nur im Speicher,
@@ -235,29 +259,42 @@
if (antwort.status === 429) { if (antwort.status === 429) {
melde('Zu viele Versuche. In etwa 10 Minuten geht es wieder.'); melde('Zu viele Versuche. In etwa 10 Minuten geht es wieder.');
} else if (fehlversuche >= 2) { } else if (fehlversuche >= 2) {
/* AUF DER CREW-WAND IST DIE AUSWAHL NICHT DAS PROBLEM /* ==== EIN SATZ FÜR BEIDE WÄNDE (03.10.2026) ================
(berichtigt 20.09.2026).
------------------------------------------------------------
Fuer die rechte Hand und die Modis gibt es dort den stillen
Zugang: `stillerZugang()` in workspace.js sucht ueber die
Codekennung, `rolle IN ('hand','modi')` -- die angetippte
Kachel spielt keine Rolle. Ihr Code oeffnet auf jeder.
Der Satz „Stimmt die Auswahl oben?" schickte sie damit in VanVan im Support, Meldung #5, zweite Runde: „Man kann sich
eine Schleife: Sie probieren alle vier Kacheln durch, jetzt nur noch über die richtige Schaltfläche anmelden. Das
sammeln vier Fehlversuche, und nach acht in zehn Minuten ist funktioniert jetzt. Aber wenn man sich 2x versucht über den
ihre Adresse gesperrt. Ein Hinweis, der die Sperre falschen Button anzumelden, dann kommt ein irreführender
herbeifuehrt, gegen die er helfen soll. Text wo steht, dass die Auswahl oben nicht schuld ist."
Auf der Agenturwand stimmt der Satz weiterhin -- dort SIE HAT RECHT, UND DER GRUND IST MEINE EIGENE ÄNDERUNG.
entscheidet die Auswahl. Deshalb wird gefragt, auf welcher Hier stand bis eben ein eigener Satz für die Crew-Wand:
Wand man steht, statt den Satz ueberall zu streichen. */ „die Auswahl oben ist nicht schuld". Er stimmte am
const crewWand = document.body.classList.contains('gate--crew'); 20.09.2026, weil es dort den STILLEN ZUGANG gab — die
melde(crewWand rechte Hand und die Modis kamen über die Codekennung
? 'Zugangscode stimmt nicht. Achte auf die Bindestriche – ' herein, egal welche Kachel sie antippten. Der Satz
+ 'die Auswahl oben ist nicht schuld.' verhinderte, dass sie alle Kacheln durchprobieren und sich
: 'Zugangscode stimmt nicht. Stimmt die Auswahl oben? ' dabei selbst aussperren.
+ 'Ein Code gehört immer zu genau einer davon.');
Mit genau diesem Bericht (#5, erste Runde) ist der stille
Zugang WEGGEFALLEN. Nachgemessen in workspace.js: Die
Kandidaten kommen aus `WHERE rolle = ?`, auf beiden Wänden.
Die Kachel entscheidet seither überall — und der Satz sagte
ab dem Moment das Gegenteil der Wahrheit, an der Stelle, an
der jemand gerade nicht weiterkommt.
Das ist die Sorte Fehler, die ein Umbau hinterlässt: Nicht
der neue Code war falsch, sondern ein Satz drei Dateien
weiter, dessen Voraussetzung er entfernt hat. Gefunden hat
ihn kein Prüflauf, sondern VanVan beim Benutzen.
EIN SATZ, KEINE ZWEI. Die Fallunterscheidung nach Wand ist
damit weg: Es gibt nur noch eine Regel, also auch nur noch
eine Auskunft. Der Hinweis auf die Bindestriche bleibt — er
galt nie nur für eine Wand. */
melde('Zugangscode stimmt nicht. Stimmt die Auswahl oben? '
+ 'Ein Code gehört immer zu genau einer davon – '
+ 'und achte auf die Bindestriche.');
} else { } else {
melde('Zugangscode stimmt nicht.'); melde('Zugangscode stimmt nicht.');
} }
+92
View File
@@ -104,7 +104,99 @@
an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' }, an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' },
}; };
/* ==== "Du bekommst gerade nichts" ================================
Am 03.10.2026 nachgemessen: 20 aktive Personen, 8 mit einer
Anmeldung. Zwoelf bekamen keine einzige Benachrichtigung --
darunter die linke Hand und ein Modi. Keiner von ihnen wusste es:
Die Glocke sagt es nur dem, der sie anschaut.
WARUM DAS HIER STEHT UND NICHT IN start.js: `lage()` ist die eine
Stelle, die weiss, woran man ist. Eine zweite Ableitung daneben
waere die, die beim naechsten Umbau etwas anderes behauptet als
die Glocke zwei Zentimeter weiter oben.
NUR WO ES ETWAS ZU AENDERN GIBT. Bei "verboten" hilft kein Knopf
(das muss man im Browser zuruecknehmen) und bei "geht-nicht"
erst recht nicht -- dort waere der Hinweis nur eine Mitteilung
ueber etwas, das man nicht tun kann. Die Glocke selbst erklaert
beide Faelle, wenn man sie antippt. */
const HINWEIS_WEG = 'dfw_push_hinweis_weg';
/* localStorage kann werfen (privater Modus, abgeschaltete Cookies).
Dann lieber den Hinweis zeigen als die Seite zerlegen -- und beim
Wegklicken nichts merken, statt beim Lesen abzustuerzen. */
const weggeklickt = () => {
try { return localStorage.getItem(HINWEIS_WEG) === 'ja'; } catch { return false; }
};
const wegMerken = () => {
try { localStorage.setItem(HINWEIS_WEG, 'ja'); } catch { /* dann eben nur diesmal */ }
};
function hinweisZeichnen() {
const platz = document.getElementById('push-hinweis');
if (!platz) return; /* die Seite will keinen -- Schluss */
const l = lage();
platz.textContent = '';
if ((l !== 'aus' && l !== 'iphone') || weggeklickt()) return;
const kasten = document.createElement('div');
kasten.className = 'push-hinweis';
const text = document.createElement('p');
text.className = 'push-hinweis__text';
const fett = document.createElement('b');
fett.textContent = 'Du bekommst gerade keine Benachrichtigungen.';
text.append(fett, ' ');
text.append(l === 'iphone'
? 'Auf dem iPhone geht das nur, wenn die Seite auf dem Home-Bildschirm liegt: '
+ 'Teilen-Knopf → „Zum Home-Bildschirm“. Danach hier noch einmal anschalten.'
: 'Neue Nachrichten, Anrufe und Antworten im Support erreichen dich auf diesem '
+ 'Gerät nur, wenn du sie anschaltest.');
const knoepfe = document.createElement('div');
knoepfe.className = 'push-hinweis__knoepfe';
const ja = document.createElement('button');
ja.type = 'button';
ja.className = 'push-hinweis__knopf push-hinweis__knopf--ja';
ja.textContent = l === 'iphone' ? 'Alles klar' : 'Anschalten';
ja.addEventListener('click', async () => {
/* Auf dem iPhone kann der Knopf nichts schalten -- dort ist er
ein Verstanden-Knopf und raeumt den Hinweis weg. */
if (l === 'iphone') { wegMerken(); hinweisZeichnen(); return; }
ja.disabled = true;
try { await anmelden(); } catch (f) { console.warn('[glocke]', f); }
ja.disabled = false;
/* `anmelden` ruft `zeichnen`, das wiederum hier landet: Klappt
es, steht `lage()` auf "an" und der Hinweis ist weg. Klappt es
nicht, bleibt er stehen -- richtig so, es hat ja nicht
geklappt. */
hinweisZeichnen();
});
const spaeter = document.createElement('button');
spaeter.type = 'button';
spaeter.className = 'push-hinweis__knopf push-hinweis__knopf--spaeter';
spaeter.textContent = 'Nicht jetzt';
spaeter.title = 'Der Hinweis kommt nicht wieder. Über die Glocke oben geht es jederzeit.';
spaeter.addEventListener('click', () => { wegMerken(); hinweisZeichnen(); });
knoepfe.append(ja, spaeter);
kasten.append(text, knoepfe);
platz.append(kasten);
}
function zeichnen() { function zeichnen() {
/* VOR der Schranke unten: Der Hinweis haengt an der LAGE, nicht
daran, ob der Knopf schon steht. Dahinter wuerde er still
ausfallen, sobald die Glocke einmal woanders sitzt.
(Ganz ohne Glocke laeuft er trotzdem nicht -- `bauen()` steigt
dann schon vorher aus. Das ist heute kein Fall: Die Startseite
hat beides. Wer den Hinweis je auf eine Seite ohne Glocke
setzt, muss ihn dort eigens anstossen.) */
hinweisZeichnen();
if (!knopf) return; if (!knopf) return;
const l = lage(); const l = lage();
const t = TEXTE[l]; const t = TEXTE[l];
+24 -9
View File
@@ -659,14 +659,29 @@
async function eintragLoeschen(e, aufgabe) { async function eintragLoeschen(e, aufgabe) {
const was = e.name || (e.link ? kurzLink(e.link) : tagText(e.datum)); const was = e.name || (e.link ? kurzLink(e.link) : tagText(e.datum));
const frage = window.frageNach /* ==== KEIN ZWEITER NOTNAGEL (02.10.2026) =======================
? await window.frageNach({
titel: `„${was}" löschen?`, Hier stand `window.frageNach ? … : window.confirm(…)`. Der
was: 'Der Eintrag verschwindet aus deinem Monatszähler.', Hintergedanke war richtig -- es soll etwas passieren, auch wenn
bleibt: e.lead_id ? 'Der Lead im Scout-CRM bleibt bestehen.' : '', die Datei fehlt. Nur hat `nachfrage.js` genau diesen Notnagel
endgueltig: true, ja: 'Löschen', SCHON, und zwar mit demselben Text wie der richtige Dialog
}) (siehe den Kopf dieser Datei: „wer den Notnagel bekommt, soll
: window.confirm(`„${was}" löschen?`); dieselbe Auskunft haben wie alle anderen").
Der Rückfall hier war der zweite -- und der schlechtere: nur
„‚X' löschen?", ohne den Satz, was verschwindet, und ohne den
Hinweis, dass der Lead im Scout-CRM bleibt. Zwei Antworten auf
dieselbe Frage, und die kürzere hätte gewonnen.
`pruef-nachfrage` hat es gemeldet („keine Seite benutzt noch
confirm() oder prompt()"); die Seite lädt `nachfrage.js`, das
prüft dieselbe Datei eine Zeile weiter. */
const frage = await window.frageNach({
titel: `„${was}“ löschen?`,
was: 'Der Eintrag verschwindet aus deinem Monatszähler.',
bleibt: e.lead_id ? 'Der Lead im Scout-CRM bleibt bestehen.' : '',
endgueltig: true, ja: 'Löschen',
});
if (!frage) return; if (!frage) return;
try { try {
const a = await hole(`${BASIS}/eintrag/${e.id}`, { method: 'DELETE' }); const a = await hole(`${BASIS}/eintrag/${e.id}`, { method: 'DELETE' });
@@ -1128,7 +1143,7 @@
platz.append(tabelle); platz.append(tabelle);
platz.append(el('p', 'mz-ziele__gilt', platz.append(el('p', 'mz-ziele__gilt',
`${leute.length} von ${d.leute.length} Personen · sortiert nach ` `${leute.length} von ${d.leute.length} Personen · sortiert nach `
+ `„${spaltenName(teamSortierung.spalte, d)}"`)); + `„${spaltenName(teamSortierung.spalte, d)}“`));
} catch { } catch {
$('team').textContent = ''; $('team').textContent = '';
$('team').append(el('p', 'mz-leer', 'Die Übersicht konnte nicht geladen werden.')); $('team').append(el('p', 'mz-leer', 'Die Übersicht konnte nicht geladen werden.'));
+242 -46
View File
@@ -82,10 +82,16 @@
gleichzeitig kann der Browser ohnehin nicht, und ein neuer je gleichzeitig kann der Browser ohnehin nicht, und ein neuer je
Aufruf wuerde den Fokus verlieren. */ Aufruf wuerde den Fokus verlieren. */
var dlg = null, feld = null, tippfeld = null, zahlfeld = null, fehlerZeile = null; var dlg = null, feld = null, tippfeld = null, zahlfeld = null, fehlerZeile = null;
/* Das gewaehlte Bild und die Adresse seiner Vorschau. Beides lebt var datumfeld = null, hakenfeld = null, hakenSperrtDatum = false;
hier und nicht im Aufruf: `bauen()` haengt die Zuhoerer einmal an, /* Die gewaehlten Bilder. Sie leben hier und nicht im Aufruf:
und die muessen auch beim naechsten Oeffnen noch dasselbe meinen. */ `bauen()` haengt die Zuhoerer einmal an, und die muessen auch
var bildfeld = null, gewaehltesBild = null, bildUrl = null; beim naechsten Oeffnen noch dasselbe meinen.
EINE LISTE SEIT DEM 02.10.2026 (vorher eine einzelne Variable).
VanVan im Support, Meldung #11: „2-3 wären besser." `bildMax`
sagt, wie viele diese eine Frage zulaesst -- die Vorgabe 1 haelt
jeden anderen Aufruf im Haus bei genau dem Verhalten von vorher. */
var bildfeld = null, gewaehlteBilder = [], bildMax = 1;
function bauen() { function bauen() {
if (dlg) return dlg; if (dlg) return dlg;
@@ -112,6 +118,41 @@
+ '<input id="nachfrage-zahl" type="number" min="1" inputmode="numeric" />' + '<input id="nachfrage-zahl" type="number" min="1" inputmode="numeric" />'
+ '<p class="nachfrage__hinweis" data-teil="zahlhinweis"></p>' + '<p class="nachfrage__hinweis" data-teil="zahlhinweis"></p>'
+ '</div>' + '</div>'
/* ==== EIN DATUM UND EIN HAKEN (02.10.2026) ==================
VanVan im Support, Meldung #6, zweite Haelfte: „Bei den
Vorlagen laesst sich weder die Frist anpassen noch
‚dauerhaft' einstellen, und eine Anmerkung fehlt auch."
DREI WUENSCHE, DREI FELDER -- und zwei davon gab es hier
noch nicht. Die Anmerkung ist `grund` (steht schon oben),
die Frist braucht ein Datum, „dauerhaft" einen Haken.
SIE STEHEN HIER UND NICHT IN vorlagenbrett.js. Ein eigener
kleiner Dialog dort waere der zweite im Haus, der dasselbe
tut -- und der, in dem beim naechsten Mal die Esc-Taste
fehlt, der Fokus nicht gefangen ist oder der Abbruch etwas
anderes zurueckgibt. Genau dafuer gibt es diese Datei.
VORGABE AUS: Ohne `datum` bzw. `haken` in den Angaben
aendert sich fuer keinen der dreissig anderen Aufrufe im
Haus irgendetwas. */
+ '<div class="nachfrage__zeile" data-teil="datumzeile" hidden>'
+ '<label class="feld-schild" data-teil="datumschild" for="nachfrage-datum"></label>'
+ '<input id="nachfrage-datum" type="date" />'
+ '</div>'
/* DER HAKEN STEHT MIT SEINER BESCHRIFTUNG IN EINER ZEILE --
anders als die Felder darueber, bei denen die Beschriftung
oben steht. Ein Kaestchen unter seinem Text sieht aus wie
ein verrutschtes Feld; daneben liest es sich als Satz. */
+ '<div class="nachfrage__zeile nachfrage__zeile--haken"'
+ ' data-teil="hakenzeile" hidden>'
+ '<label class="nachfrage__haken">'
+ '<input id="nachfrage-haken" type="checkbox" />'
+ '<span data-teil="hakenschild"></span>'
+ '</label>'
+ '<p class="nachfrage__hinweis" data-teil="hakenhinweis"></p>'
+ '</div>'
/* ==== EIN BILD, WENN DIE FRAGE EINES BRAUCHT (02.10.2026) ==== /* ==== EIN BILD, WENN DIE FRAGE EINES BRAUCHT (02.10.2026) ====
VanVan (Support): „Wenn man hier im Support auf deine Frage VanVan (Support): „Wenn man hier im Support auf deine Frage
@@ -133,7 +174,7 @@
zehn anderen Aufrufer im Haus nichts. */ zehn anderen Aufrufer im Haus nichts. */
+ '<div class="nachfrage__zeile" data-teil="bildzeile" hidden>' + '<div class="nachfrage__zeile" data-teil="bildzeile" hidden>'
+ '<span class="feld-schild" data-teil="bildschild"></span>' + '<span class="feld-schild" data-teil="bildschild"></span>'
+ '<input id="nachfrage-bild" type="file" accept="image/*" hidden />' + '<input id="nachfrage-bild" type="file" accept="image/*" multiple hidden />'
+ '<div class="nachfrage__bild">' + '<div class="nachfrage__bild">'
+ '<button type="button" class="knopf knopf--klein" data-teil="bildwaehlen">' + '<button type="button" class="knopf knopf--klein" data-teil="bildwaehlen">'
+ 'Bild wählen</button>' + 'Bild wählen</button>'
@@ -141,7 +182,7 @@
+ '<button type="button" class="abmelden" data-teil="bildweg" hidden>' + '<button type="button" class="abmelden" data-teil="bildweg" hidden>'
+ 'Entfernen</button>' + 'Entfernen</button>'
+ '</div>' + '</div>'
+ '<img class="nachfrage__vorschau" data-teil="bildvorschau" alt="" hidden />' + '<div class="nachfrage__vorschauen" data-teil="bildvorschauen" hidden></div>'
+ '</div>' + '</div>'
+ '<p class="nachfrage__fehler" data-teil="fehler" role="alert"></p>' + '<p class="nachfrage__fehler" data-teil="fehler" role="alert"></p>'
+ '<div class="dialog__knoepfe">' + '<div class="dialog__knoepfe">'
@@ -153,6 +194,22 @@
tippfeld = dlg.querySelector('#nachfrage-tipp'); tippfeld = dlg.querySelector('#nachfrage-tipp');
zahlfeld = dlg.querySelector('#nachfrage-zahl'); zahlfeld = dlg.querySelector('#nachfrage-zahl');
bildfeld = dlg.querySelector('#nachfrage-bild'); bildfeld = dlg.querySelector('#nachfrage-bild');
datumfeld = dlg.querySelector('#nachfrage-datum');
hakenfeld = dlg.querySelector('#nachfrage-haken');
/* ==== EIN DATUM, DAS NICHT GILT, IST SCHLIMMER ALS KEINS ======
Gilt fuer die eine Kombination, in der sich die beiden Felder
widersprechen: Eine DAUERHAFTE Aufgabe hat keine Frist (sie
waere ab dem naechsten Tag fuer immer ueberfaellig -- und eine
Warnung, die immer kommt, ist keine mehr). Setzt jemand den
Haken, waere das Datum darueber eine Angabe, die der Server
verwirft, ohne es zu sagen.
Deshalb wird es GRAU und nicht etwa heimlich ignoriert. Dass
der Dialog diese Verbindung ueberhaupt kennt, kostet eine
Angabe (`hakenSperrtDatum`) -- und ist billiger als ein
zweiter Dialog an der einen Stelle, die sie braucht. */
hakenfeld.addEventListener('change', datumSperre);
fehlerZeile = dlg.querySelector('[data-teil="fehler"]'); fehlerZeile = dlg.querySelector('[data-teil="fehler"]');
/* DIE DREI ZUHOERER NUR EINMAL. `bauen()` laeuft genau einmal -- /* DIE DREI ZUHOERER NUR EINMAL. `bauen()` laeuft genau einmal --
@@ -162,53 +219,134 @@
teil('bildwaehlen').addEventListener('click', function () { bildfeld.click(); }); teil('bildwaehlen').addEventListener('click', function () { bildfeld.click(); });
teil('bildweg').addEventListener('click', function () { teil('bildweg').addEventListener('click', function () {
bildfeld.value = ''; bildfeld.value = '';
bildSetzen(null); gewaehlteBilder = [];
bilderZeichnen();
}); });
bildfeld.addEventListener('change', function (e) { bildfeld.addEventListener('change', function (e) {
var datei = (e.target.files && e.target.files[0]) || null; bilderDazu(e.target.files ? Array.prototype.slice.call(e.target.files) : []);
/* NUR BILDER -- derselbe Satz wie beim Melden. Der Server sagt /* DAS FELD WIRD GELEERT, die Liste nicht. Sonst meldet der
es auch, aber erst nach dem Hochladen; hier steht es, bevor Browser beim zweiten Antippen DESSELBEN Bildes kein
jemand wartet. */ `change` -- er sieht keine Aenderung -- und es saehe aus, als
if (datei && datei.type && datei.type.indexOf('image/') !== 0) { waere der Knopf kaputt. */
fehlerZeile.textContent = 'Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.'; e.target.value = '';
bildfeld.value = '';
bildSetzen(null);
return;
}
fehlerZeile.textContent = '';
bildSetzen(datei);
}); });
return dlg; return dlg;
} }
/** Was gewaehlt ist -- auf dem Bildschirm und in der Variablen. /** Neu Gewaehltes DAZULEGEN statt ersetzen.
*
* VanVan, Meldung #11, zweite Runde: „wenn man es nacheinander
* versucht hinzuzufügen wird das Bild immer nur ersetzt." Auch mit
* `multiple` ersetzt jede neue Auswahl die `files`-Liste komplett;
* auf dem Handy tippt man aber dreimal statt einmal. */
function bilderDazu(neue) {
var meldung = '';
/* ==== BEI GENAU EINEM PLATZ IST EINE NEUE WAHL EINE KORREKTUR ==
Zwei Faelle, eine Regel: Mehr als `bildMax` geht nie.
Ist `bildMax` 1, gibt es nichts, wozu man etwas DAZULEGEN
koennte -- wer dann noch einmal waehlt, meint „doch lieber
dieses". Ihn erst auf „Entfernen" zu schicken waere ein
Klick, der nichts klaert. Das ist genau das Verhalten, das
dieser Dialog seit dem 02.10. frueh hatte, und es bleibt.
Ab zwei Plaetzen wird gefuellt und das Ueberzaehlige
ABGELEHNT -- dort waere „ersetzen" der Fehler, den VanVan
gemeldet hat: Das zuerst Gewaehlte verschwaende unbemerkt. */
if (bildMax === 1 && neue.length) gewaehlteBilder = [];
for (var i = 0; i < neue.length; i++) {
var datei = neue[i];
if (gewaehlteBilder.length >= bildMax) {
meldung = bildMax === 1
? 'Hier geht nur ein Bild.'
: 'Mehr als ' + bildMax + ' Bilder gehen nicht.';
break;
}
/* NUR BILDER -- derselbe Satz wie beim Melden. Der Server sagt
es auch, aber erst nach dem Hochladen; hier steht es, bevor
jemand wartet. */
if (datei.type && datei.type.indexOf('image/') !== 0) {
meldung = 'Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.';
continue;
}
var schon = gewaehlteBilder.some(function (d) {
return d.name === datei.name && d.size === datei.size
&& d.lastModified === datei.lastModified;
});
if (!schon) gewaehlteBilder.push(datei);
}
fehlerZeile.textContent = meldung;
bilderZeichnen();
}
/** Was gewaehlt ist -- auf dem Bildschirm.
* *
* DIE VORSCHAU IST DER PUNKT. Wer sieht, was er anhaengt, haengt * DIE VORSCHAU IST DER PUNKT. Wer sieht, was er anhaengt, haengt
* seltener das falsche Bild an; dieselbe Ueberlegung wie auf der * seltener das falsche Bild an; dieselbe Ueberlegung wie auf der
* Supportseite beim Melden. `createObjectURL` wird wieder * Supportseite beim Melden.
* freigegeben -- sonst haelt jedes abgewaehlte Bild den Speicher *
* bis zum naechsten Laden der Seite. */ * JEDES BILD HAT SEIN EIGENES KREUZ, seit es mehrere sein duerfen:
function bildSetzen(datei) { * Wer das dritte falsch erwischt hat, soll nicht alle drei neu
if (bildUrl) { try { URL.revokeObjectURL(bildUrl); } catch (e) { /* egal */ } } * waehlen muessen.
bildUrl = null; *
gewaehltesBild = datei || null; * DIE ADRESSEN WERDEN WIEDER FREIGEGEBEN -- sonst haelt jede
* Vorschau ihr Bild bis zum naechsten Laden der Seite im
* Arbeitsspeicher fest. */
function bilderZeichnen() {
var name = teil('bildname'); var name = teil('bildname');
var weg = teil('bildweg'); var weg = teil('bildweg');
var schau = teil('bildvorschau'); var schau = teil('bildvorschauen');
if (!datei) {
var alte = schau.querySelectorAll('img[data-url]');
for (var a = 0; a < alte.length; a++) {
try { URL.revokeObjectURL(alte[a].dataset.url); } catch (e) { /* egal */ }
}
schau.textContent = '';
if (!gewaehlteBilder.length) {
name.textContent = 'Noch keins gewählt'; name.textContent = 'Noch keins gewählt';
weg.hidden = true; weg.hidden = true;
schau.hidden = true; schau.hidden = true;
schau.removeAttribute('src');
return; return;
} }
name.textContent = datei.name + ' · ' + Math.round(datei.size / 1024) + ' KB'; name.textContent = gewaehlteBilder.length === 1
? gewaehlteBilder[0].name + ' · ' + Math.round(gewaehlteBilder[0].size / 1024) + ' KB'
: gewaehlteBilder.length + ' von ' + bildMax + ' Bildern';
weg.hidden = false; weg.hidden = false;
bildUrl = URL.createObjectURL(datei);
schau.src = bildUrl; gewaehlteBilder.forEach(function (datei, i) {
var kasten = document.createElement('div');
kasten.className = 'nachfrage__vorschau';
var url = URL.createObjectURL(datei);
var img = document.createElement('img');
img.dataset.url = url;
img.src = url;
img.alt = 'Vorschau von ' + datei.name;
kasten.appendChild(img);
var raus = document.createElement('button');
raus.type = 'button';
raus.className = 'nachfrage__vorschau-weg';
raus.textContent = '✕';
raus.title = datei.name + ' wieder entfernen';
raus.setAttribute('aria-label', datei.name + ' wieder entfernen');
raus.addEventListener('click', function () {
gewaehlteBilder.splice(i, 1);
bilderZeichnen();
});
kasten.appendChild(raus);
schau.appendChild(kasten);
});
schau.hidden = false; schau.hidden = false;
} }
function datumSperre() {
if (!dlg) return;
var aus = hakenSperrtDatum && hakenfeld.checked;
datumfeld.disabled = aus;
teil('datumzeile').dataset.aus = aus ? 'ja' : '';
}
function teil(name) { return dlg.querySelector('[data-teil="' + name + '"]'); } function teil(name) { return dlg.querySelector('[data-teil="' + name + '"]'); }
/** Setzt Text und blendet die Zeile aus, wenn es keinen gibt. /** Setzt Text und blendet die Zeile aus, wenn es keinen gibt.
@@ -265,10 +403,24 @@
var zahlMax = o.zahlMax || 0; var zahlMax = o.zahlMax || 0;
var zahlLeer = !!o.zahlLeer; var zahlLeer = !!o.zahlLeer;
var zahlHinweis = o.zahlHinweis || ''; var zahlHinweis = o.zahlHinweis || '';
/* EIN BILD DAZU -- die Beschriftung der Zeile, leer heisst: kein /* BILDER DAZU -- die Beschriftung der Zeile, leer heisst: kein
Bildfeld. Nie Pflicht: Ein Bildschirmfoto hilft, aber wer Bildfeld. Nie Pflicht: Ein Bildschirmfoto hilft, aber wer
keines hat, soll trotzdem antworten koennen. */ keines hat, soll trotzdem antworten koennen.
`bildMax` sagt, wie viele. Vorgabe 1 -- ohne diese Angabe
verhaelt sich der Dialog wie vor dem 02.10.2026. */
var bild = o.bild || ''; var bild = o.bild || '';
bildMax = Math.max(1, Number(o.bildMax) || 1);
/* EIN DATUM (Beschriftung, leer heisst: kein Datumsfeld) und ein
HAKEN. `datumWert` ist die Vorgabe -- sie steht schon drin,
damit niemand die uebliche Frist von Hand tippen muss; genau
das war VanVans Punkt, nicht „ein leeres Feld mehr". */
var datum = o.datum || '';
var datumWert = o.datumWert || '';
var datumMin = o.datumMin || '';
var haken = o.haken || '';
var hakenAn = !!o.hakenAn;
var hakenHinweis = o.hakenHinweis || '';
hakenSperrtDatum = !!o.hakenSperrtDatum;
var NIE_ZURUECK = 'Das lässt sich danach nicht mehr rückgängig machen.'; var NIE_ZURUECK = 'Das lässt sich danach nicht mehr rückgängig machen.';
/* ---- Ausgang 2: kein <dialog> -> confirm() mit DEMSELBEN Text ---- /* ---- Ausgang 2: kein <dialog> -> confirm() mit DEMSELBEN Text ----
@@ -304,10 +456,21 @@
/* IM NOTAUSGANG GIBT ES KEIN BILD, und das wird gesagt statt /* IM NOTAUSGANG GIBT ES KEIN BILD, und das wird gesagt statt
verschwiegen: `window.prompt` kann keine Datei. Wer hier verschwiegen: `window.prompt` kann keine Datei. Wer hier
landet, hat einen Browser ohne `<dialog>` -- er kann landet, hat einen Browser ohne `<dialog>` -- er kann
antworten, nur eben ohne Anhang. `bild: null` sorgt dafuer, antworten, nur eben ohne Anhang. Die LEERE LISTE sorgt
dass die aufrufende Stelle nicht raten muss. */ dafuer, dass die aufrufende Stelle nicht raten muss -- und
if (bild) erg.bild = null; dass sie dieselbe Form bekommt wie im Normalfall, statt
return Promise.resolve((grund || zahl || eingabe || bild) ? erg : true); einmal `null` und einmal ein Feld. */
if (bild) erg.bilder = [];
/* IM NOTAUSGANG BLEIBEN DATUM UND HAKEN AUF DER VORGABE.
`window.prompt` kann kein Datum und keinen Haken; was man
hier zurueckgibt, muss trotzdem BRAUCHBAR sein -- also das,
was auch ohne Zutun gegolten haette. Ein `null` wuerde die
aufrufende Stelle zwingen, denselben Vorgabewert ein zweites
Mal zu kennen. */
if (datum) erg.datum = datumWert;
if (haken) erg.haken = hakenAn;
return Promise.resolve((grund || zahl || eingabe || bild || datum || haken)
? erg : true);
} }
bauen(); bauen();
@@ -348,6 +511,19 @@
if (zahlMax) zahlfeld.max = String(zahlMax); else zahlfeld.removeAttribute('max'); if (zahlMax) zahlfeld.max = String(zahlMax); else zahlfeld.removeAttribute('max');
zahlfeld.required = !zahlLeer; zahlfeld.required = !zahlLeer;
} }
teil('datumzeile').hidden = !datum;
if (datum) {
teil('datumschild').textContent = datum;
datumfeld.value = datumWert;
if (datumMin) datumfeld.min = datumMin; else datumfeld.removeAttribute('min');
}
teil('hakenzeile').hidden = !haken;
if (haken) {
teil('hakenschild').textContent = haken;
teil('hakenhinweis').textContent = hakenHinweis;
hakenfeld.checked = hakenAn;
}
datumSperre();
teil('bildzeile').hidden = !bild; teil('bildzeile').hidden = !bild;
if (bild) { if (bild) {
teil('bildschild').textContent = bild; teil('bildschild').textContent = bild;
@@ -355,7 +531,8 @@
/* JEDES OEFFNEN FAENGT LEER AN. Ohne diese Zeile haengt beim /* JEDES OEFFNEN FAENGT LEER AN. Ohne diese Zeile haengt beim
zweiten Mal noch das Bild vom ersten dran -- und niemand zweiten Mal noch das Bild vom ersten dran -- und niemand
sieht es, weil der Dialog oben schon zu war. */ sieht es, weil der Dialog oben schon zu war. */
bildSetzen(null); gewaehlteBilder = [];
bilderZeichnen();
} }
fehlerZeile.textContent = ''; fehlerZeile.textContent = '';
@@ -424,12 +601,31 @@
} }
erg2.zahl = n; erg2.zahl = n;
} }
/* `null`, wenn keins gewaehlt wurde -- und nicht `undefined`. /* IMMER EINE LISTE, auch wenn sie leer ist -- und nicht
Die aufrufende Stelle fragt dann `antwort.bild`, bekommt `undefined`. Die aufrufende Stelle schleift darueber und
eine klare Absage und muss nicht wissen, ob das Feld muss weder wissen, ob das Feld angeboten wurde, noch den
ueberhaupt angeboten wurde. */ Sonderfall „genau eines" kennen. */
if (bild) erg2.bild = gewaehltesBild || null; /* DAS DATUM WIRD GEPRUEFT, BEVOR DER DIALOG ZUGEHT. Ein
antwort = (grund || zahl || eingabe || bild) ? erg2 : true; leeres Feld ist zulaessig (dann gilt die Vorgabe des
Servers); ein Datum in der Vergangenheit nicht -- eine
Frist, die schon vorbei ist, macht die Aufgabe im selben
Moment rot, in dem sie entsteht. */
if (datum && !datumfeld.disabled && datumfeld.value && datumMin
&& datumfeld.value < datumMin) {
fehlerZeile.textContent = 'Dieses Datum liegt in der Vergangenheit.';
datumfeld.focus(); return;
}
/* GESPERRT HEISST LEER. Sonst schickte der Browser ein
Datum mit, das der Server wegwirft -- und beim naechsten
Lesen stuende in der Antwort etwas anderes als in der
Datenbank. */
if (datum) {
erg2.datum = (hakenSperrtDatum && hakenfeld.checked)
? '' : (datumfeld.value || datumWert);
}
if (haken) erg2.haken = hakenfeld.checked;
if (bild) erg2.bilder = gewaehlteBilder.slice();
antwort = (grund || zahl || eingabe || bild || datum || haken) ? erg2 : true;
dlg.close(); dlg.close();
} }
+20
View File
@@ -611,6 +611,26 @@ Der Code gilt einmal fuer die `
werte.append(zuletzt); werte.append(zuletzt);
links.append(werte); links.append(werte);
/* WER NICHTS BEKOMMT, STEHT HIER -- und nur der (03.10.2026).
Am 03.10. nachgemessen: 20 aktive Personen, 8 mit einer
Anmeldung. Zwölf bekommen keine einzige Benachrichtigung,
darunter ein Modi und die linke Hand. Wer etwas Wichtiges
schreibt, soll nicht raten müssen, wen es erreicht.
NUR BEIM FEHLEN, UND DAS IST ABSICHT. Stünde bei jeder Person
eine Zeile, stünde bei zwanzig Personen zwanzigmal etwas da,
und die acht, auf die es ankommt, gingen darin unter — eine
Angabe, die immer kommt, wird nicht mehr gelesen. Schweigen
heißt hier: erreichbar.
Nur bei AKTIVEN: Bei jemandem, der nicht mehr dabei ist, ist
es keine Lücke, sondern richtig so. */
if (p.aktiv && !p.push_geraete) {
links.append(el('div', 'person__bezug',
'bekommt keine Benachrichtigungen – die Glocke ist bei ihr/ihm aus'));
}
/* Beim Scout: zu wem er gehoert. Das ist keine Zahl, sondern ein /* Beim Scout: zu wem er gehoert. Das ist keine Zahl, sondern ein
Name -- der steht als eigene Zeile unter dem Band. */ Name -- der steht als eigene Zeile unter dem Band. */
if (p.rolle === 'scout' && p.manager_name) { if (p.rolle === 'scout' && p.manager_name) {
+13 -11
View File
@@ -66,10 +66,6 @@
darüber -- sonst stünden irgendwann zwei verschiedene Wahrheiten darüber -- sonst stünden irgendwann zwei verschiedene Wahrheiten
übereinander auf einem Bildschirm. */ übereinander auf einem Bildschirm. */
const HINWEIS_ZU_KACHEL = { const HINWEIS_ZU_KACHEL = {
/* Kein Kacheleintrag fuer `kein_geraet`: Die Anruf-Probe hat keine
eigene Kachel -- sie ist ein Werkzeug, kein Bereich. Der Hinweis
bleibt dadurch in der Hausfarbe, und das ist richtig: Er gehoert
zu keinem Bereich, sondern zum Geraet in der Hand. */
aufgaben_ueberfaellig: 'Aufgaben', aufgaben_ueberfaellig: 'Aufgaben',
aufgaben_heute: 'Aufgaben', aufgaben_heute: 'Aufgaben',
aufgaben_review: 'Aufgaben', aufgaben_review: 'Aufgaben',
@@ -1513,16 +1509,22 @@
/* HINWEISE OHNE ZAHL (19.09.2026). /* HINWEISE OHNE ZAHL (19.09.2026).
Vor jedem Hinweis steht seine Anzahl -- das ist richtig, solange Vor jedem Hinweis steht seine Anzahl -- das ist richtig, solange
er Dinge zaehlt ("3 Aufgaben sind ueberfaellig"). „Bei dir er Dinge zaehlt ("3 Aufgaben sind ueberfaellig"). Ein Hinweis,
klingelt nichts" zaehlt aber nichts, es ist ein Zustand. Eine „1“ der einen ZUSTAND beschreibt, zaehlt nichts; eine „1“ davor
davor liest sich wie ein Zaehlfehler. liest sich dort wie ein Zaehlfehler.
Als SATZ und nicht als Sonderfall im Aufbau: Wer hier einen Die Liste ist seit dem 03.10.2026 leer: Ihr einziger Eintrag war
weiteren Zustand ergaenzt, traegt ihn in diese eine Liste ein. */ „kein_geraet" („Bei dir klingelt nichts"), und der steht jetzt
const OHNE_ZAHL = new Set(['kein_geraet']); als Kasten mit Anschalt-Knopf ueber dieser Liste -- siehe
workspace-hinweise.js, dort steht auch warum.
SIE BLEIBT TROTZDEM STEHEN, leer. Der naechste Zustandshinweis
braucht sie wieder, und als Liste ist sie die eine Stelle dafuer
-- ein Sonderfall im Aufbau waere die Stelle, an der es beim
naechsten Mal wieder schiefgeht. */
const OHNE_ZAHL = new Set([]);
const EINZAHL = { const EINZAHL = {
kein_geraet: () => 'Bei dir klingelt nichts – kein Gerät angemeldet',
aufgaben_ueberfaellig: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig`, aufgaben_ueberfaellig: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig`,
aufgaben_heute: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig`, aufgaben_heute: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig`,
aufgaben_review: (n) => `${n} ${n === 1 ? 'Aufgabe wartet' : 'Aufgaben warten'} auf Freigabe`, aufgaben_review: (n) => `${n} ${n === 1 ? 'Aufgabe wartet' : 'Aufgaben warten'} auf Freigabe`,
+435 -103
View File
@@ -33,7 +33,25 @@
let leitung = false; let leitung = false;
let meldungen = []; let meldungen = [];
let filter = 'offen'; let filter = 'offen';
let gewaehltesBild = null; /* ==== BIS ZU DREI BILDER (02.10.2026) ==========================
Hier stand `let gewaehltesBild = null` — eine Variable für genau
ein Bild, und jede neue Auswahl überschrieb sie. Das war VanVans
zweite Meldung dazu, wörtlich: „wenn man es nacheinander versucht
hinzuzufügen wird das Bild immer nur ersetzt."
JETZT EINE LISTE, und `bilderMax` kommt vom Server. Eine feste 3
an dieser Stelle wäre die Zahl, die beim Ändern vergessen wird —
dasselbe Muster wie die 380 px vom 06.09. */
let gewaehlteBilder = [];
let bilderMax = 3;
/* Welche Meldungen ihren GANZEN Verlauf zeigen. Ein Set und keine
einzelne Nummer: Es koennen mehrere Karten gleichzeitig offen
sein, und wer zwei vergleicht, will nicht, dass sich die erste
schliesst, sobald er die zweite aufmacht.
Nur Anzeige, deshalb hier und nicht am Server -- beim naechsten
Laden ist wieder zu, und das ist richtig so. */
const verlaufGanz = new Set();
/* Anzeige-Wörter, keine Regel. Der Server schickt den Schlüssel; was /* Anzeige-Wörter, keine Regel. Der Server schickt den Schlüssel; was
daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */ daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */
@@ -109,51 +127,140 @@
MELDEN MELDEN
=================================================================== */ =================================================================== */
function bildZeigen(datei) { /** Die Auswahl neu zeichnen — Name, Knöpfe, Vorschaubilder.
gewaehltesBild = datei || null; *
* JEDES BILD HAT SEIN EIGENES KREUZ. Wer versehentlich das falsche
* dritte Bild erwischt, soll nicht alle drei neu wählen müssen;
* genau dort hört man sonst auf und schickt eben nur eines.
*
* DIE ADRESSEN WERDEN WIEDER FREIGEGEBEN (`revokeObjectURL`).
* Ohne das hält jede Vorschau ihr Bild bis zum Neuladen der Seite
* im Arbeitsspeicher fest — bei drei Handyfotos sind das schnell
* fünfzehn Megabyte, die niemand mehr braucht. */
function bilderZeigen() {
const name = $('bild-name'); const name = $('bild-name');
const weg = $('bild-weg'); const weg = $('bild-weg');
const vorschau = $('vorschau'); const vorschau = $('vorschau');
if (!datei) {
name.textContent = ''; /* Erst die alten Adressen zurückgeben, dann neu bauen. */
for (const img of vorschau.querySelectorAll('img[data-url]')) {
URL.revokeObjectURL(img.dataset.url);
}
vorschau.textContent = '';
if (!gewaehlteBilder.length) {
/* ==== SAGEN, DASS MEHRERE GEHEN (03.10.2026) ===============
Hier stand eine leere Zeile. Die Funktion kann seit dem
02.10. drei Bilder -- VanVans Meldung #11 -- nur erfuhr es
niemand: Der Knopf hiess „Bild anhaengen", die Unterzeile
sagte „haeng ein Bild dran", und daneben stand nichts.
Eine Moeglichkeit, von der man nichts weiss, gibt es nicht.
Das ist dieselbe Sorte wie ein Knopf, der eine Absage holt
-- nur andersherum.
DIE ZAHL KOMMT VOM SERVER (`bilderMax`). Eine feste 3 hier
waere die zweite, die beim Aendern vergessen wird. */
name.textContent = `bis zu ${bilderMax} Bilder`;
weg.hidden = true; weg.hidden = true;
vorschau.hidden = true; vorschau.hidden = true;
$('vorschau-bild').removeAttribute('src');
return; return;
} }
name.textContent = `${datei.name} · ${Math.round(datei.size / 1024)} KB`;
const kb = gewaehlteBilder.reduce((s, d) => s + d.size, 0) / 1024;
/* UND WENN SCHON EINES DALIEGT, STEHT DA, DASS NOCH PLATZ IST.
„eins.png · 24 KB" allein sagt nicht, dass ein zweites geht --
und genau an dieser Stelle hoert jemand auf. */
const frei = bilderMax - gewaehlteBilder.length;
const rest = frei > 0
? ` · noch ${frei} möglich`
: '';
name.textContent = gewaehlteBilder.length === 1
? `${gewaehlteBilder[0].name} · ${Math.round(kb)} KB${rest}`
: `${gewaehlteBilder.length} von ${bilderMax} Bildern · ${Math.round(kb)} KB`;
weg.hidden = false; weg.hidden = false;
/* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server: Sie
ist noch gar nicht hochgeladen. `URL.createObjectURL` zeigt das for (const [i, datei] of gewaehlteBilder.entries()) {
Bild aus dem Arbeitsspeicher — wer sieht, was er anhängt, hängt const kasten = el('div', 's-vorschau__eins');
nicht versehentlich das falsche an. */ /* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server:
const url = URL.createObjectURL(datei); Sie ist noch gar nicht hochgeladen. `URL.createObjectURL`
const img = $('vorschau-bild'); zeigt das Bild aus dem Arbeitsspeicher — wer sieht, was er
img.onload = () => URL.revokeObjectURL(url); anhängt, hängt nicht versehentlich das falsche an. */
img.src = url; const url = URL.createObjectURL(datei);
const img = el('img');
img.dataset.url = url;
img.src = url;
img.alt = `Vorschau von ${datei.name}`;
kasten.append(img);
const raus = el('button', 's-vorschau__weg', '✕');
raus.type = 'button';
raus.title = `${datei.name} wieder entfernen`;
raus.setAttribute('aria-label', `${datei.name} wieder entfernen`);
raus.addEventListener('click', () => {
gewaehlteBilder.splice(i, 1);
bilderZeigen();
});
kasten.append(raus);
vorschau.append(kasten);
}
vorschau.hidden = false; vorschau.hidden = false;
} }
/** Neu gewählte Dateien DAZULEGEN statt ersetzen.
*
* ==== VANVANS ZWEITE MELDUNG (02.10.2026) =======================
*
* „wenn man es nacheinander versucht hinzuzufügen wird das Bild
* immer nur ersetzt."
*
* `multiple` am Feld allein löst das NICHT: Auch mit `multiple`
* ersetzt jede neue Auswahl die `files`-Liste vollständig. Auf dem
* Handy wählt man aber selten drei Bilder in einem Rutsch — man
* tippt dreimal. Deshalb wird hier angehängt.
*
* DOPPELTE WERDEN NICHT ZWEIMAL GENOMMEN. Wer das Feld zweimal
* öffnet und zweimal dasselbe Foto antippt, hat eines gemeint.
* Erkannt an Name, Größe und Änderungszeit — zwei verschiedene
* Bildschirmfotos stimmen in allen dreien nicht überein. */
function bilderDazu(neue) {
let abgelehnt = '';
for (const datei of neue) {
if (gewaehlteBilder.length >= bilderMax) {
abgelehnt = `Mehr als ${bilderMax} Bilder gehen nicht – nimm die wichtigsten.`;
break;
}
/* DIE GRENZE STEHT HIER NUR ALS FREUNDLICHKEIT. Abgelehnt wird
am Server; dieser Hinweis erspart es, zwölf Megabyte erst
hochzuladen, um danach eine Absage zu lesen. */
if (datei.size > 12 * 1024 * 1024) {
abgelehnt = `„${datei.name}“ ist größer als 12 MB – bitte ein kleineres.`;
continue;
}
const schon = gewaehlteBilder.some((d) => d.name === datei.name
&& d.size === datei.size && d.lastModified === datei.lastModified);
if (schon) continue;
gewaehlteBilder.push(datei);
}
melde('melden-fehler', abgelehnt);
bilderZeigen();
}
function meldenEinrichten() { function meldenEinrichten() {
$('bild-waehlen').addEventListener('click', () => $('bild').click()); $('bild-waehlen').addEventListener('click', () => $('bild').click());
$('bild-weg').addEventListener('click', () => { $('bild-weg').addEventListener('click', () => {
$('bild').value = ''; $('bild').value = '';
bildZeigen(null); gewaehlteBilder = [];
bilderZeigen();
}); });
$('bild').addEventListener('change', (e) => { $('bild').addEventListener('change', (e) => {
const datei = e.target.files?.[0] || null; bilderDazu([...(e.target.files || [])]);
if (!datei) { bildZeigen(null); return; } /* DAS FELD WIRD GELEERT, die Liste nicht. Sonst meldet der
/* DIE GRENZE STEHT HIER NUR ALS FREUNDLICHKEIT. Abgelehnt wird Browser beim zweiten Antippen DESSELBEN Bildes kein
am Server; dieser Hinweis erspart es, zwölf Megabyte erst `change` — er sieht keine Änderung — und es sähe aus, als
hochzuladen, um danach eine Absage zu lesen. */ wäre der Knopf kaputt. */
if (datei.size > 12 * 1024 * 1024) { e.target.value = '';
melde('melden-fehler', 'Das Bild ist größer als 12 MB – bitte ein kleineres.');
$('bild').value = '';
bildZeigen(null);
return;
}
melde('melden-fehler', '');
bildZeigen(datei);
}); });
$('melden').addEventListener('submit', async (e) => { $('melden').addEventListener('submit', async (e) => {
@@ -169,14 +276,47 @@
melde('melden-fehler', ''); melde('melden-fehler', '');
$('melden-hinweis').textContent = 'wird geschickt …'; $('melden-hinweis').textContent = 'wird geschickt …';
try { try {
/* DER TEXT REIST IM KOPF, das Bild im Rumpf — derselbe Weg wie /* DER TEXT REIST IM KOPF, die Bilder im Rumpf — derselbe Weg
bei Chat-Anhängen. Ein Weg für beides, damit es keinen wie bei Chat-Anhängen. Ein Weg für beides, damit es keinen
Zustand „Meldung da, Bild fehlt noch" gibt. */ Zustand „Meldung da, Bild fehlt noch" gibt.
const kopf = { 'x-text': encodeURIComponent(text), 'x-seite': location.pathname }; Was `x-bilder` bedeutet, steht in workspace-support.js bei
`rumpfZerlegen`. */
const kopf = {
'x-text': encodeURIComponent(text),
'x-seite': location.pathname,
/* ==== DIESE ZEILE IST PFLICHT (02.10.2026) ==============
Hier stand gar kein Typ. Das ging gut, solange der Rumpf
eine EINZELNE Datei war: Ein `File` bringt seinen Typ
mit, und `fetch` setzt daraus den Kopf. Ein `Blob` aus
mehreren Dateien hat KEINEN Typ -- dann schickt `fetch`
die Zeile gar nicht, `express.raw` fuehlt sich mit
seinem Sternchen-Muster nicht zustaendig (ohne Kopf
passt kein Muster), und der Server bekommt einen leeren
Rumpf.
(Das Muster steht hier absichtlich NICHT woertlich da:
Ein Sternchen mit Schraegstrich beendet diesen
Kommentar. Genau daran ist der erste Versuch dieser
Zeile gescheitert -- die Seite lud danach gar nicht
mehr, und die Pruefung hat es in derselben Minute
gemeldet.)
FOLGE WAERE GEWESEN: Die Meldung geht durch, der Text
kommt an, die Bilder sind weg -- ohne Fehler, ohne
Meldung. Genau das, was VanVan gemeldet hat, nur
schlimmer. Die Pruefungen am Server waren dabei gruen;
gefunden hat es erst `pruef-support-bilder` im echten
Browser. */
'Content-Type': 'application/octet-stream',
};
if (gewaehlteBilder.length) {
kopf['x-bilder'] = gewaehlteBilder.map((d) => d.size).join(',');
}
const a = await hole('/workspace/api/support', { const a = await hole('/workspace/api/support', {
method: 'POST', method: 'POST',
headers: kopf, headers: kopf,
body: gewaehltesBild || new Blob([]), body: new Blob(gewaehlteBilder),
}); });
if (!a.ok) { if (!a.ok) {
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.')); melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
@@ -184,7 +324,8 @@
} }
$('text').value = ''; $('text').value = '';
$('bild').value = ''; $('bild').value = '';
bildZeigen(null); gewaehlteBilder = [];
bilderZeigen();
$('melden-hinweis').textContent = 'Danke – ist angekommen.'; $('melden-hinweis').textContent = 'Danke – ist angekommen.';
setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000); setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000);
await laden(); await laden();
@@ -201,28 +342,81 @@
EINE MELDUNG ALS KARTE EINE MELDUNG ALS KARTE
=================================================================== */ =================================================================== */
function bildAnhaengen(karte, m) { /** Welchen Farbton hat Runde n?
if (!m.bild) return; *
* ==== GERECHNET, NICHT GEPFLEGT (03.10.2026) ====================
*
* Filipe: „jede runde soll auch immer eine spezielle farbe haben."
*
* Eine Liste mit fuenf Farben waere die naheliegende Loesung und
* die falsche: Runde sechs bekaeme keine. Im Haus hat genau diese
* Sorte Liste schon dreimal etwas gekostet (die 380-px-Schwelle,
* die abgeschriebenen Spalten, der Kachelsatz). Eine Formel kann
* nicht veralten.
*
* DER BEREICH IST ABSICHTLICH ENG: 200 bis 296 Grad, also Blau
* ueber Indigo nach Violett. Das ist die Palette der Seite
* (babyblau mit lila, Filipes Wunsch vom 25.09.) -- und es ist der
* Bereich, in dem KEINE Farbe nach „gut" oder „schlecht" aussieht.
* Gruen und Bernstein sind fuer den AUSGANG reserviert („Geht
* wieder" / „Ging noch nicht"); waere eine Rundenfarbe rot,
* stuenden zwei Aussagen in einer Farbe.
*
* VIER TOENE, DANN VON VORN. Mehr als vier Schritte in diesem
* engen Bereich waeren nicht mehr auseinanderzuhalten -- und die
* Nummer steht ohnehin daneben. Farbe allein traegt hier keine
* Aussage; sie hilft dem Auge, Zeilen zuzuordnen.
*
* SAETTIGUNG UND HELLIGKEIT STEHEN IM CSS und sind fuer alle
* Toene gleich. Nur so ist der Kontrast bei jeder Runde derselbe
* -- sonst waere Runde drei blasser als Runde eins, und
* augenschonend ist das Gegenteil von zufaellig. */
function rundenTon(nr) {
const n = Number(nr);
const i = (Number.isFinite(n) && n > 0 ? n - 1 : 0) % 4;
return 200 + i * 32;
}
/** Die Bilder einer Meldung oder einer Antwortrunde anzeigen.
*
* ==== EINE FUNKTION FÜR BEIDE STELLEN (02.10.2026) ==============
*
* Hier stand `bildAnhaengen(karte, m)` für das eine Bild der
* Meldung, und weiter unten noch einmal fast derselbe Block für
* das Bild einer Runde. Mit bis zu drei Bildern wären daraus zwei
* Schleifen geworden, die dasselbe tun und beim nächsten Mal nur
* an einer Stelle verbessert werden.
*
* Der Server schickt an beiden Stellen dieselbe Form (`bilder`:
* Liste aus `{ weg, breite, hoehe }`), deshalb genügt eine
* Funktion und ein Alternativtext, der sagt, wo man ist. */
function bilderAnhaengen(wohin, bilder, wortDazu) {
const liste = Array.isArray(bilder) ? bilder : [];
if (!liste.length) return;
const kasten = el('div', 's-karte__bild'); const kasten = el('div', 's-karte__bild');
const a = el('a'); if (liste.length > 1) kasten.dataset.viele = 'ja';
a.href = m.bild; for (const [i, b] of liste.entries()) {
a.target = '_blank'; const a = el('a');
a.rel = 'noopener'; a.href = b.weg;
a.title = 'In voller Größe ansehen'; a.target = '_blank';
const img = document.createElement('img'); a.rel = 'noopener noreferrer';
img.src = m.bild; a.title = 'In voller Größe ansehen';
img.alt = 'Bild zur Meldung'; const img = el('img');
img.loading = 'lazy'; img.src = b.weg;
/* BREITE UND HÖHE STEHEN DRAN, wenn der Server sie kennt: Dann img.alt = liste.length > 1
reserviert der Browser den Platz, bevor das Bild da ist, und die ? `${wortDazu} – Bild ${i + 1} von ${liste.length}`
Liste springt beim Laden nicht. */ : wortDazu;
if (m.bild_breite > 0 && m.bild_hoehe > 0) { img.loading = 'lazy';
img.width = m.bild_breite; img.decoding = 'async';
img.height = m.bild_hoehe; /* BREITE UND HÖHE STEHEN DRAN, wenn der Server sie kennt: Dann
reserviert der Browser den Platz, bevor das Bild da ist, und
die Liste springt beim Laden nicht. */
if (b.breite > 0 && b.hoehe > 0) { img.width = b.breite; img.height = b.hoehe; }
a.append(img);
kasten.append(a);
} }
a.append(img); wohin.append(kasten);
kasten.append(a);
karte.append(kasten);
} }
function karteBauen(m) { function karteBauen(m) {
@@ -245,7 +439,7 @@
/* `textContent` mit erhaltenen Zeilenumbrüchen (das CSS setzt /* `textContent` mit erhaltenen Zeilenumbrüchen (das CSS setzt
`white-space: pre-wrap`). Kein innerHTML, siehe der Kopf. */ `white-space: pre-wrap`). Kein innerHTML, siehe der Kopf. */
k.append(el('p', 's-karte__text', m.text)); k.append(el('p', 's-karte__text', m.text));
bildAnhaengen(k, m); bilderAnhaengen(k, m.bilder, 'Bild zur Meldung');
if (leitung && m.seite) { if (leitung && m.seite) {
const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite); const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite);
@@ -270,47 +464,164 @@
als der alte Satz. */ als der alte Satz. */
const verlauf = Array.isArray(m.verlauf) ? m.verlauf : []; const verlauf = Array.isArray(m.verlauf) ? m.verlauf : [];
if (verlauf.length) { if (verlauf.length) {
/* ==== DER VERLAUF ALS ZEITACHSE (03.10.2026) ================
Filipe: „ich will diese komplette seite vom support viel
uebersichtlicher, profissioneller, jede runde soll auch immer
eine spezielle farbe haben und anders aufgestellt aufgeteilt
sein damit man einen viel besseren und krasseren ueberblick
hat."
WAS VORHER DASTAND (gemessen an einem Bild mit zwei Runden):
vier Textzeilen untereinander, alle in derselben Farbe --
„RUNDE 1", Antwort, „Ging noch nicht · …", „RUNDE 2". Bei
fuenf Runden ist das eine Wand, in der man die Runden zaehlen
muss, statt sie zu sehen.
DREI DINGE SIND NEU:
1. EINE LEISTE OBEN, die alle Runden auf einen Blick zeigt --
ein Punkt je Runde, in ihrer Farbe, mit ihrem Ausgang.
Damit ist „wie oft ging es schon hin und her?" eine Frage
des Hinsehens.
2. EINE ZEITACHSE statt einer Liste: links eine Linie, je
Runde ein Punkt darauf. Man sieht die Abfolge, nicht nur
die Reihenfolge.
3. ZWEI SPRECHRICHTUNGEN je Runde, deutlich getrennt: was
die Leitung getan hat, und was der Melder daraufhin
gesagt hat. Vorher stand beides als Absatz untereinander
und sah gleich aus.
DIE FARBE WIRD GERECHNET, NICHT GEPFLEGT. Eine Liste mit
fuenf Farben waere die Stelle, an der Runde sechs keine mehr
bekommt -- und im Haus hat genau diese Sorte Liste schon
dreimal etwas gekostet. Der Farbton ergibt sich aus der
Rundennummer (siehe `rundenTon`). */
const liste = el('div', 's-verlauf'); const liste = el('div', 's-verlauf');
for (const r of verlauf) {
/* DIE LEISTE -- nur ab zwei Runden. Bei einer waere sie ein
Punkt neben einem Punkt und sagte nichts. */
if (verlauf.length > 1) {
const leiste = el('div', 's-rundenleiste');
leiste.setAttribute('aria-hidden', 'true');
for (const r of verlauf) {
const p = el('span', 's-rundenleiste__punkt', String(r.nr));
p.style.setProperty('--ton', String(rundenTon(r.nr)));
if (r.urteil) p.dataset.urteil = r.urteil;
leiste.append(p);
}
liste.append(leiste);
/* FUER VORLESEPROGRAMME EIN SATZ statt elf Punkte. Die Leiste
ist ein Bild; was sie sagt, steht hier in Worten. */
const offen = verlauf.filter((r) => !r.urteil).length;
liste.append(el('p', 's-nurlesen',
`${verlauf.length} Runden`
+ (offen ? `, die letzte wartet noch auf eine Antwort` : '')));
}
/* ==== DIE LETZTEN ZWEI OFFEN (03.10.2026) ==================
Filipe: „damit man einen viel besseren und krasseren
ueberblick hat."
Die Leiste oben sagt, WIE VIELE Runden es gab. Darunter
braucht es nicht alle im Wortlaut: Bei sechs Runden sind das
zwoelf Kaesten, und die Karte ist wieder die Wand, die sie
vorher war.
ZWEI, NICHT EINE: Die letzte Runde ist das, was gerade
gilt -- die vorletzte sagt, woran es davor lag. Erst beide
zusammen erklaeren, warum es noch laeuft.
UND NICHT NULL: Wer die Karte oeffnet, will lesen, wo es
steht, nicht erst klicken. Dieselbe Ueberlegung wie bei den
Bewerbungsantworten heute Nacht -- und dort hat sie eine
Messung bestanden: 922 px auf einem 844-px-Bildschirm waren
zu viel, drei offene Zeilen sind es nicht. */
const GANZ = verlaufGanz.has(m.id);
const RUNDEN_OFFEN = 2;
const frueher = Math.max(0, verlauf.length - RUNDEN_OFFEN);
if (frueher && !GANZ) {
const mehr = el('button', 's-verlauf__mehr');
mehr.type = 'button';
mehr.setAttribute('aria-expanded', 'false');
mehr.append(el('span', null, frueher === 1
? 'Eine frühere Runde zeigen'
: `${frueher} frühere Runden zeigen`));
mehr.append(el('span', 's-verlauf__pfeil', '▾'));
mehr.addEventListener('click', () => {
verlaufGanz.add(m.id);
listeZeichnen();
});
liste.append(mehr);
}
const sichtbar = (frueher && !GANZ) ? verlauf.slice(frueher) : verlauf;
for (const r of sichtbar) {
const runde = el('div', 's-runde'); const runde = el('div', 's-runde');
runde.style.setProperty('--ton', String(rundenTon(r.nr)));
if (r.urteil) runde.dataset.urteil = r.urteil;
const kopfR = el('div', 's-runde__kopf'); const kopfR = el('div', 's-runde__kopf');
kopfR.append(el('span', 's-runde__nr', 'Runde ' + r.nr)); kopfR.append(el('span', 's-runde__nr', 'Runde ' + r.nr));
if (r.von) kopfR.append(el('span', 's-runde__von', r.von)); if (r.von) kopfR.append(el('span', 's-runde__von', r.von));
runde.append(kopfR); if (r.am) {
runde.append(el('p', 's-runde__antwort', r.antwort)); const wann = el('span', 's-runde__zeit', herZeit(r.am));
if (r.urteil) { wann.title = genau(r.am);
const u = el('p', 's-runde__urteil'); kopfR.append(wann);
u.dataset.urteil = r.urteil;
u.append(el('span', 's-runde__urteil-wort',
r.urteil === 'geht' ? 'Geht wieder' : 'Ging noch nicht'));
if (r.urteil_text) u.append(document.createTextNode(' · ' + r.urteil_text));
runde.append(u);
} }
/* ==== DAS BILD ZUR ANTWORT (02.10.2026) ================== runde.append(kopfR);
Es steht BEI DER RUNDE, zu der es gehört, nicht oben bei /* (1) WAS DIE LEITUNG GETAN HAT. */
der Meldung. Nach drei Runden ist sonst nicht mehr zu const vonOben = el('div', 's-schritt');
sehen, welches Bild zu welchem Versuch gehörte -- und vonOben.dataset.wer = 'leitung';
genau diese Frage löst einen wiederkehrenden Fehler. vonOben.append(el('span', 's-schritt__schild', 'Geantwortet'));
vonOben.append(el('p', 's-schritt__text', r.antwort));
runde.append(vonOben);
ANKLICKBAR UND IN EIGENEM FENSTER: dieselbe Behandlung wie /* (2) WAS DER MELDER DARAUFHIN GESAGT HAT -- und die Bilder
das Bild der Meldung weiter oben. Ein Bildschirmfoto in dazu gehoeren in DIESEN Kasten, nicht unter die Runde:
Briefmarkengröße beantwortet keine Frage. */ Sie sind sein Beleg, nicht ihrer. */
if (r.urteil_bild) { if (r.urteil) {
const a = el('a', 's-runde__bild'); const vonUnten = el('div', 's-schritt');
a.href = r.urteil_bild; vonUnten.dataset.wer = 'melder';
a.target = '_blank'; vonUnten.dataset.urteil = r.urteil;
a.rel = 'noopener noreferrer'; vonUnten.append(el('span', 's-schritt__schild',
const img = el('img'); r.urteil === 'geht' ? 'Geht wieder' : 'Ging noch nicht'));
img.src = r.urteil_bild; if (r.urteil_text) vonUnten.append(el('p', 's-schritt__text', r.urteil_text));
img.loading = 'lazy'; bilderAnhaengen(vonUnten, r.bilder,
img.decoding = 'async'; 'Bildschirmfoto zur Antwort in Runde ' + r.nr);
img.alt = 'Bildschirmfoto zur Antwort in Runde ' + r.nr; runde.append(vonUnten);
a.append(img); } else {
runde.append(a); /* NOCH KEINE ANTWORT -- und das wird gesagt, statt die
Zeile weglassen. Eine Runde ohne Rueckmeldung sieht sonst
aus wie eine, bei der etwas fehlt. */
const warte = el('div', 's-schritt');
warte.dataset.wer = 'warte';
warte.append(el('span', 's-schritt__schild', 'Wartet auf Antwort'));
runde.append(warte);
bilderAnhaengen(runde, r.bilder,
'Bildschirmfoto zur Antwort in Runde ' + r.nr);
} }
liste.append(runde); liste.append(runde);
} }
/* UND WIEDER ZU. Ein Knopf, der nur aufklappt, ist ein
Einwegschalter -- und das faellt erst auf, wenn jemand
zurueck will. */
if (frueher && GANZ) {
const weniger = el('button', 's-verlauf__mehr');
weniger.type = 'button';
weniger.setAttribute('aria-expanded', 'true');
weniger.append(el('span', null, 'Nur die letzten zwei Runden'));
weniger.append(el('span', 's-verlauf__pfeil', '▴'));
weniger.addEventListener('click', () => {
verlaufGanz.delete(m.id);
listeZeichnen();
});
liste.append(weniger);
}
k.append(liste); k.append(liste);
} else if (m.antwort) { } else if (m.antwort) {
const ant = el('div', 's-antwort'); const ant = el('div', 's-antwort');
@@ -375,11 +686,17 @@
neues auf, hilft das alte Bild niemandem. neues auf, hilft das alte Bild niemandem.
NIE PFLICHT. Wer keines hat, antwortet ohne -- ein NIE PFLICHT. Wer keines hat, antwortet ohne -- ein
Bildschirmfoto ist Hilfe, keine Hürde. */ Bildschirmfoto ist Hilfe, keine Hürde.
bild: 'Bildschirmfoto dazu (freiwillig)',
SEIT DEM 02.10. BIS ZU DREI, genau wie beim Melden.
VanVan, Meldung #11: „2-3 wären besser." Dass es beim
Melden drei sein dürfen und bei der Antwort eines, wäre
dieselbe Frage mit zwei Antworten. */
bild: 'Bildschirmfotos dazu (freiwillig)',
bildMax: bilderMax,
}); });
if (!antwort) return; if (!antwort) return;
await rueckmelden(m, false, antwort.grund || '', antwort.bild || null); await rueckmelden(m, false, antwort.grund || '', antwort.bilder || []);
}); });
reihe.append(nicht); reihe.append(nicht);
return reihe; return reihe;
@@ -395,24 +712,28 @@
* OHNE BILD IST DER RUMPF LEER, und das ist zulässig. Dann steht * OHNE BILD IST DER RUMPF LEER, und das ist zulässig. Dann steht
* im Kopf alles, was es zu sagen gibt. * im Kopf alles, was es zu sagen gibt.
*/ */
async function rueckmelden(m, geht, text, bild) { async function rueckmelden(m, geht, text, bilder) {
melde('eingang-fehler', ''); melde('eingang-fehler', '');
const anhang = Array.isArray(bilder) ? bilder : (bilder ? [bilder] : []);
try { try {
const kopf = {
/* Der Typ sagt, was im RUMPF liegt. Bei mehreren Bildern ist
das kein einzelnes Bildformat mehr, sondern eine Folge --
`application/octet-stream` ist dafür die ehrlichste
Auskunft, und der Server erkennt jedes Stück ohnehin an
seinen ersten Bytes statt an dieser Zeile. */
'Content-Type': 'application/octet-stream',
'x-geht': geht ? 'ja' : 'nein',
/* Prozentkodiert, weil ein Kopf keine Umlaute trägt -- und
„Grüße" in einer Begründung ist der Normalfall, nicht die
Ausnahme. Genauso macht es das Melden. */
'x-text': encodeURIComponent(text || ''),
};
if (anhang.length) kopf['x-bilder'] = anhang.map((d) => d.size).join(',');
const a = await hole(`/workspace/api/support/${m.id}/rueckmeldung`, { const a = await hole(`/workspace/api/support/${m.id}/rueckmeldung`, {
method: 'POST', method: 'POST',
headers: { headers: kopf,
/* Der Typ sagt, was im RUMPF liegt. Ohne Bild ist das body: new Blob(anhang),
nichts -- `application/octet-stream` ist dafür die
ehrlichste Auskunft. */
'Content-Type': bild ? (bild.type || 'application/octet-stream')
: 'application/octet-stream',
'x-geht': geht ? 'ja' : 'nein',
/* Prozentkodiert, weil ein Kopf keine Umlaute trägt -- und
„Grüße" in einer Begründung ist der Normalfall, nicht die
Ausnahme. Genauso macht es das Melden. */
'x-text': encodeURIComponent(text || ''),
},
body: bild || new Blob([]),
}); });
/* DURCH DIESELBE UEBERSETZUNG WIE ALLES ANDERE (25.09.2026, /* DURCH DIESELBE UEBERSETZUNG WIE ALLES ANDERE (25.09.2026,
gefunden von pruef-meldungen). Hier stand `f.fehler` roh -- gefunden von pruef-meldungen). Hier stand `f.fehler` roh --
@@ -622,6 +943,17 @@
const r = await a.json(); const r = await a.json();
leitung = r.leitung === true; leitung = r.leitung === true;
meldungen = Array.isArray(r.meldungen) ? r.meldungen : []; meldungen = Array.isArray(r.meldungen) ? r.meldungen : [];
/* WIE VIELE BILDER GEHEN, SAGT DER SERVER (02.10.2026). Steht
es nicht in der Antwort (eine Seite aus dem Zwischenspeicher
trifft auf einen neuen Server oder umgekehrt), bleibt der
Vorgabewert — eine fehlende Zahl darf die Auswahl nicht auf
null setzen. */
if (Number.isInteger(r.bilder_max) && r.bilder_max > 0) bilderMax = r.bilder_max;
/* UND DIE ZEILE NEU SCHREIBEN, jetzt mit der richtigen Zahl.
Ohne das stuende beim ersten Laden der Vorgabewert da --
heute zufaellig derselbe, morgen vielleicht nicht. Eine Zahl,
die zufaellig stimmt, ist keine Auskunft. */
bilderZeigen();
/* DIE SEITE STELLT SICH AUF DEN UM, DER SIE ÖFFNET. */ /* DIE SEITE STELLT SICH AUF DEN UM, DER SIE ÖFFNET. */
$('eingang-block').hidden = !leitung; $('eingang-block').hidden = !leitung;
+54 -2
View File
@@ -174,7 +174,59 @@
}); });
} }
/* ==== STUMM HAENGEN IST DER SCHLIMMSTE AUSGANG (05.10.2026) =====
Filipe: "ich lade video hoch und es erscheint immer noch nicht",
auf Nachfrage: die Seite bleibt haengen, es kommt KEINE Meldung.
`los()` baut die Seite auf und macht dabei keine einzige
Netzanfrage -- es kann also nur stolpern, nicht warten. Und wenn
es stolpert, passiert genau das, was er beschreibt: Die erste
Zeile wirft, `ladezeile.hidden = true` wird nie erreicht, und die
Seite steht fuer immer auf "wird geladen". Kein Fehler, kein
Hinweis, nichts zum Weitermelden -- und von aussen nicht zu
unterscheiden von "der Server antwortet nicht".
DAS IST EIN EIGENER MANGEL, unabhaengig von der Ursache: Eine
Seite, die nicht sagen kann, dass sie kaputt ist, kostet jeden
Fehler doppelt -- einmal den Fehler und einmal die Suche danach.
Ich habe heute Stunden gebraucht, um von aussen auszuschliessen,
was diese drei Zeilen in einer Sekunde gesagt haetten.
Die Meldung nennt den Grund im Klartext. Sie ist nicht schoen,
aber sie ist da -- und der Weg zum Brett steht daneben, damit das
Video trotzdem hineinkommt. */
function losSicher() {
try {
los();
} catch (fehler) {
const z = (id) => document.getElementById(id);
if (z('ladezeile')) z('ladezeile').hidden = true;
if (z('teilen')) z('teilen').setAttribute('aria-busy', 'false');
/* Das Einfuegefeld aufmachen: Auch wenn das Erkennen des Links
misslungen ist, kann man ihn von Hand einsetzen. */
if (z('haendisch')) z('haendisch').hidden = false;
if (z('wahl')) z('wahl').hidden = false;
if (z('knoepfe')) z('knoepfe').hidden = false;
melde('Beim Aufbau der Seite ist etwas schiefgegangen: '
+ String(fehler?.message || fehler)
+ ' — füg den Link unten von Hand ein, das geht trotzdem.');
/* Und der Knopf muss trotzdem gehen, sonst ist die Meldung ein
Trost ohne Ausweg. */
const k = z('uebernehmen');
if (k && !k.dataset.bereit) {
k.dataset.bereit = 'ja';
k.addEventListener('click', () => {
const url = z('eingabe')?.value?.trim();
if (!url) { melde('Da fehlt noch der Link zum Video.'); return; }
uebernehmen(url);
});
}
console.error('[teilen]', fehler);
}
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', los); document.addEventListener('DOMContentLoaded', losSicher);
} else { los(); } } else { losSicher(); }
})(); })();
+240 -8
View File
@@ -177,6 +177,14 @@
let absagen = null; let absagen = null;
let darfEntscheidenV = false; let darfEntscheidenV = false;
let darfVerteilenV = false; let darfVerteilenV = false;
/* Ob die gelesenen Antworten gerade aufgeklappt sind. Nur Anzeige,
deshalb hier und nicht am Server: Wer nachlesen will, macht auf;
beim naechsten Laden ist wieder zu, und das ist richtig so. */
let aeltereOffen = false;
/* Ob die ungelesenen Antworten ueber die ersten drei hinaus
ausgeklappt sind. Nur Anzeige, deshalb hier und nicht am Server:
Beim naechsten Laden ist wieder zu, und das ist richtig so. */
let mehrAntworten = false;
let vBereich = 'live'; let vBereich = 'live';
let vStufe = 'anfaenger'; let vStufe = 'anfaenger';
@@ -452,6 +460,7 @@
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?'; if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer)); k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer));
fuss.append(k); fuss.append(k);
fuss.append(anpassenKnopf(v, { art: 'aufgabe', nr }, fuer, darfVerteilenV));
} }
karte.append(fuss); karte.append(fuss);
box.append(karte); box.append(karte);
@@ -558,12 +567,25 @@
Absage daneben wäre eine Liste, die nur schlechte Nachrichten Absage daneben wäre eine Liste, die nur schlechte Nachrichten
sammelt. */ sammelt. */
if (antworten.length) { if (antworten.length) {
const band = el('div', 'vorlagenbrett__antwort'); /* ==== GELESEN RUTSCHT NACH UNTEN (03.10.2026) ==============
band.append(el('p', 'vorlagenbrett__antwort-schild',
antworten.length === 1 VanVan im Support, Meldung #15: „Die Modis können die
? 'Antwort auf deine Bewerbung:' Antworten auf die Bewerbungen im Bereich eure Aufgaben noch
: `${antworten.length} Antworten auf deine Bewerbungen:`)); nicht einklappen. Das wird mit der Zeit unübersichtlich."
for (const a2 of antworten) {
KEIN SCHALTER, DER ALLES WEGKLAPPT. Der hätte auch die
nächste Absage mitversteckt — also genau die Zeile, derent-
wegen dieser Kasten am 30.09. überhaupt entstanden ist.
Stattdessen: Was man mit „Verstanden" quittiert hat, wandert
hinter eine zugeklappte Zeile. Neues steht weiter offen da.
Damit gilt die Begründung von vorgestern („eine Antwort, die
man erst aufklappen muss, ist wieder keine") unverändert
weiter — sie galt immer nur für die UNGELESENE. */
const neueA = antworten.filter((a2) => !a2.gesehen);
const alteA = antworten.filter((a2) => a2.gesehen);
const zeileBauen = (a2, mitKnopf) => {
const z = el('div', 'vorlagenbrett__antwort-zeile'); const z = el('div', 'vorlagenbrett__antwort-zeile');
z.dataset.zustand = a2.zustand; z.dataset.zustand = a2.zustand;
z.append(el('span', 'vorlagenbrett__antwort-wort', z.append(el('span', 'vorlagenbrett__antwort-wort',
@@ -577,9 +599,98 @@
'„' + a2.entscheid_text + '“' '„' + a2.entscheid_text + '“'
+ (a2.von_name ? ' – ' + a2.von_name : ''))); + (a2.von_name ? ' – ' + a2.von_name : '')));
} }
band.append(z); if (mitKnopf) {
const k = el('button', 'vorlagenbrett__antwort-weg', 'Verstanden');
k.type = 'button';
k.title = 'Rutscht zu den älteren Antworten – nachlesen kannst du sie weiter.';
k.addEventListener('click', () => gesehenMelden(k, { id: a2.id }));
z.append(k);
}
return z;
};
if (neueA.length) {
const band = el('div', 'vorlagenbrett__antwort');
band.append(el('p', 'vorlagenbrett__antwort-schild',
neueA.length === 1
? 'Antwort auf deine Bewerbung:'
: `${neueA.length} Antworten auf deine Bewerbungen:`));
/* ==== HOECHSTENS DREI AUF EINMAL (03.10.2026) ===========
GEMESSEN, NICHT GESCHAETZT: Mit den neun Antworten, die
ein Modi im Haus wirklich liegen hat, war dieses Band auf
dem Handy 922 px hoch -- bei 844 px Fenster. Es fuellte
also mehr als den Bildschirm und machte 49 % der ganzen
Seite aus. Das ist woertlich VanVans Satz: „Das wird mit
der Zeit unuebersichtlich."
Mein erster Umbau loeste das erst NACH einem Griff auf
„Alle verstanden". Vorher stand die Wand trotzdem da --
also die halbe Antwort.
DREI STEHEN OFFEN, der Rest ist einen Griff entfernt.
Warum nicht null: Eine Absage, die man aufklappen muss,
ist keine Nachricht mehr -- das war die Begruendung vom
30.09., und sie gilt. Warum nicht alle: siehe oben.
Drei ist die Zahl, bei der das Band mit Kopf und
Sammelknopf unter einem halben Bildschirm bleibt.
DIE NEUESTEN ZUERST -- der Server sortiert nach
`entschieden_am DESC`. Wer aufklappt, findet die aelteren
darunter; wer nicht aufklappt, hat die neuesten gesehen. */
const OFFEN_MAX = 3;
const zeigen = mehrAntworten ? neueA : neueA.slice(0, OFFEN_MAX);
for (const a2 of zeigen) band.append(zeileBauen(a2, true));
if (neueA.length > OFFEN_MAX) {
const mehr = el('button', 'vorlagenbrett__antwort-kopf');
mehr.type = 'button';
mehr.setAttribute('aria-expanded', mehrAntworten ? 'true' : 'false');
mehr.append(el('span', null, mehrAntworten
? 'Weniger zeigen'
: `und ${neueA.length - OFFEN_MAX} weitere`));
mehr.append(el('span', 'vorlagenbrett__antwort-pfeil',
mehrAntworten ? '▴' : '▾'));
mehr.addEventListener('click', () => {
mehrAntworten = !mehrAntworten;
vorlagenbrettZeigen();
});
band.append(mehr);
}
/* AB ZWEI EIN KNOPF FÜR ALLE. Wer nach dem Urlaub sieben
Antworten vorfindet, soll nicht siebenmal tippen — und
sieben Anfragen wären sieben Gelegenheiten, dass eine
unterwegs verlorengeht. */
if (neueA.length > 1) {
const alleK = el('button', 'abmelden vorlagenbrett__antwort-alle',
`Alle ${neueA.length} verstanden`);
alleK.type = 'button';
alleK.addEventListener('click', () => gesehenMelden(alleK, { alle: true }));
band.append(alleK);
}
ziel.append(band);
}
if (alteA.length) {
const band = el('div', 'vorlagenbrett__antwort');
band.dataset.alt = 'ja';
const kopfA = el('button', 'vorlagenbrett__antwort-kopf');
kopfA.type = 'button';
kopfA.setAttribute('aria-expanded', aeltereOffen ? 'true' : 'false');
kopfA.append(el('span', null, alteA.length === 1
? '1 ältere Antwort'
: `${alteA.length} ältere Antworten`));
kopfA.append(el('span', 'vorlagenbrett__antwort-pfeil', aeltereOffen ? '▴' : '▾'));
kopfA.addEventListener('click', () => {
aeltereOffen = !aeltereOffen;
vorlagenbrettZeigen();
});
band.append(kopfA);
/* ZUGEKLAPPT WIRD GAR NICHT ERST GEBAUT — dieselbe Regel wie
beim Brett darunter: Zeilen anzulegen, die niemand sieht,
ist Arbeit für nichts. */
if (aeltereOffen) for (const a2 of alteA) band.append(zeileBauen(a2, false));
ziel.append(band);
} }
ziel.append(band);
} }
@@ -1146,6 +1257,14 @@
}); });
}); });
fussTun.append(k); fussTun.append(k);
/* DERSELBE KNOPF WIE BEI DEN CREATOR-VORLAGEN (02.10.2026).
VanVan, Meldung #6 -- und sie verteilt aus DIESEM Katalog,
nicht aus dem anderen. Dass es ihn dort auch gibt, ist
Folge derselben Funktion, nicht einer zweiten Fassung. */
fussTun.append(anpassenKnopf(v, {
art: 'katalog', nr: v.nr, gruppe: kGruppe,
person_id: katalogZiel(), nochmal: wirklichNochmal,
}, undefined, true));
} else if (meine) { } else if (meine) {
/* SCHON BEWORBEN. Der Satz sagt, WER antwortet -- sonst wartet /* SCHON BEWORBEN. Der Satz sagt, WER antwortet -- sonst wartet
man auf niemanden und fragt am nächsten Tag im Chat. */ man auf niemanden und fragt am nächsten Tag im Chat. */
@@ -1521,6 +1640,119 @@
} }
} }
/* =====================================================================
ANPASSEN STATT NUR UEBERNEHMEN (02.10.2026)
=====================================================================
VanVan im Support, Meldung #6, zweite Haelfte: „Bei den Vorlagen
laesst sich weder die Frist anpassen noch ‚dauerhaft' einstellen,
und eine Anmerkung fehlt auch."
EIN ZWEITER KNOPF UND NICHT EIN FENSTER FUER ALLE. „An alle"
verteilt zwoelf Aufgaben mit einem Druck; wuerde jedes Mal ein
Fenster aufgehen, waere der haeufige Weg langsamer geworden, um
den seltenen zu ermoeglichen. Jetzt gibt es beides: schnell
uebernehmen wie bisher, oder „Anpassen …" fuer den Fall, in dem
es auf Frist, Dauer oder ein Wort dazu ankommt.
DAS FENSTER IST DAS DES HAUSES (`frageNach`), erweitert um ein
Datum und einen Haken. Ein eigener kleiner Dialog hier waere der
zweite, in dem Esc, Fokus und Abbruch von Hand nachgebaut werden
muessten.
„DAUERHAFT" NUR, WER VERTEILEN DARF -- und abgelehnt wird am
Server. Der Haken fehlt hier nur, damit niemand eine Absage
holt; die Schranke liegt in der Route.
DIE VORGABE IM DATUMSFELD IST DIE FRIST DER VORLAGE. Ein leeres
Feld haette geheissen: „rechne selbst, in wie vielen Tagen" --
und genau das Rechnen nimmt die Vorlage einem ab. */
function tageSpaeter(tage) {
/* `Number(tage) || 7` STAND HIER UND WAR FALSCH (gefunden von
pruef-aufgaben-vorlagen, 02.10.2026). Null ist in JavaScript
unwahr -- `heuteLokal()` lieferte damit nicht heute, sondern
den Tag in einer Woche, und das Datumsfeld liess kein frueheres
Datum mehr zu. Die Vorgabe „in 1 Tag" lag damit UNTER der
erlaubten Untergrenze: Wer das Fenster oeffnete und einfach
„Uebernehmen" drueckte, bekam eine Absage.
`Number.isFinite` fragt, ob eine Zahl da ist -- nicht, ob sie
wahr ist. Das ist derselbe Unterschied wie bei `kill -0`:
„null" und „nichts" sind zwei verschiedene Antworten. */
const n = Number(tage);
const d = new Date(Date.now() + (Number.isFinite(n) ? n : 7) * 86400000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+ `-${String(d.getDate()).padStart(2, '0')}`;
}
function heuteLokal() { return tageSpaeter(0); }
function anpassenKnopf(v, basis, fuer, darfDauerhaft) {
const k = el('button', 'abmelden vorlagenbrett__anpassen', 'Anpassen …');
k.type = 'button';
k.title = 'Frist, Dauer und eine Anmerkung festlegen, bevor sie angelegt wird';
k.addEventListener('click', async () => {
const a = await window.frageNach({
titel: 'Anpassen: ' + v.titel,
was: 'Die Aufgabe wird mit diesen Angaben angelegt.',
bleibt: 'Die Vorlage selbst bleibt unverändert – das hier gilt nur für dieses Mal.',
ja: 'Übernehmen',
datum: 'Frist',
datumWert: tageSpaeter(v.tage),
datumMin: heuteLokal(),
haken: darfDauerhaft ? 'Dauerhafte Aufgabe (wird nicht abgehakt)' : '',
hakenHinweis: darfDauerhaft
? 'Sie bleibt stehen und läuft immer weiter. Beenden kann sie nur die '
+ 'Leitung – und eine Frist hat sie dann nicht mehr.'
: '',
/* Der Haken macht das Datum grau, statt es stillschweigend
wegzuwerfen: Eine dauerhafte Aufgabe hat keine Frist. */
hakenSperrtDatum: true,
grund: 'Anmerkung (freiwillig)',
platzhalter: 'z. B. Bitte zuerst mit Rieke absprechen.',
});
if (!a) return;
uebernehmen(k, {
...basis,
frist: a.datum || undefined,
dauerhaft: a.haken === true ? true : undefined,
anmerkung: (a.grund || '').trim() || undefined,
}, fuer);
});
return k;
}
/** „Verstanden" — die Antwort zur Kenntnis nehmen.
*
* DER SERVER MERKT ES SICH, nicht der Browser. Dieselbe Antwort
* auf dem Handy noch einmal als neu zu sehen, nachdem man sie am
* Rechner gelesen hat, wäre genau die Unübersichtlichkeit, die
* VanVan gemeldet hat — nur eine Tür weiter.
*
* DIE FRISCHE LISTE KOMMT MIT DER ANTWORT. Ein zweiter Abruf
* danach wäre eine zweite Gelegenheit zu scheitern, und dann
* stünde die Zeile noch da, obwohl sie weg ist. */
async function gesehenMelden(knopf, was) {
knopf.disabled = true;
try {
const a = await U.hole('/workspace/api/vorlagen/bewerbung/gesehen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(was),
});
if (!a.ok) {
U.melde('Das hat nicht geklappt.');
knopf.disabled = false;
return;
}
const d = await a.json().catch(() => ({}));
if (Array.isArray(d.antworten)) antworten = d.antworten;
await vorlagenbrettZeigen();
} catch {
U.melde('Das hat nicht geklappt.');
knopf.disabled = false;
}
}
async function uebernehmen(knopf, was, fuer) { async function uebernehmen(knopf, was, fuer) {
knopf.disabled = true; knopf.disabled = true;
const vorher = knopf.textContent; const vorher = knopf.textContent;
+26 -1
View File
@@ -528,7 +528,32 @@
if (aktiv.liste.contains(e.target) || aktiv.knopf.contains(e.target)) return; if (aktiv.liste.contains(e.target) || aktiv.knopf.contains(e.target)) return;
schliessen(); schliessen();
}); });
addEventListener('resize', schliessen); /* EINE GROESSENAENDERUNG SCHLIESST NICHTS MEHR -- sie stellt um
(05.10.2026, gemeldet von Diene).
"Beim Kalender laesst sich die Art noch nicht einstellen, das
Menue ploppt nur ganz kurz auf und verschwindet direkt wieder.
Das selbe bei den Wiederholungen."
Hier stand `addEventListener('resize', schliessen)`. Auf dem
Rechner ist das harmlos: Dort aendert sich die Fenstergroesse nur,
wenn jemand sie aendert. Auf einem Handy aendert sie sich
dauernd -- die Adressleiste faehrt beim kleinsten Scrollen ein
und aus, die Bildschirmtastatur kommt und geht, und jedes Mal
feuert `resize`. Die Liste ging auf, der Browser meldete eine
neue Hoehe, und sie war wieder zu.
Es ist derselbe Fehler wie am 04.09. beim Scrollen ("wenn ich da
scollen will geht das immer zu"), nur eine Zeile tiefer: Ein
Ereignis, das BEIM BEDIENEN entsteht, wird als Grund zum
Abbrechen genommen.
Richtig ist, was `stelle()` ohnehin kann: neu ausrichten. Die
Liste haengt an `position: fixed` und muss nach einer
Groessenaenderung sowieso neu gesetzt werden -- sonst stuende sie
neben ihrem Knopf. Damit ueberlebt sie die Tastatur UND sitzt
hinterher richtig. */
addEventListener('resize', () => { if (aktiv) stelle(aktiv); });
/* SCROLLEN IN DER LISTE DARF SIE NICHT SCHLIESSEN (04.09.2026, von Filipe gemeldet: /* SCROLLEN IN DER LISTE DARF SIE NICHT SCHLIESSEN (04.09.2026, von Filipe gemeldet:
"wenn ich da scollen will geht das immer zu"). "wenn ich da scollen will geht das immer zu").
+14 -2
View File
@@ -411,8 +411,20 @@
Ohne Gegenmassnahme waere jede Zuteilung fuer immer auf Ohne Gegenmassnahme waere jede Zuteilung fuer immer auf
„angenommen" stehen geblieben. Deshalb zieht der Statuswechsel „angenommen" stehen geblieben. Deshalb zieht der Statuswechsel
die Zuteilung jetzt mit (`zuteilungenNachStatus` im Server). die Zuteilung jetzt mit (`zuteilungenNachStatus` im Server).
Der Weg `/mein-stand` bleibt bestehen -- er ist die Schranke, NACHTRAG 03.10.2026 -- UND HIER STAND EINE AUSREDE. Woertlich:
falls ihn jemand direkt anspricht. */ „Der Weg `/mein-stand` bleibt bestehen -- er ist die Schranke,
falls ihn jemand direkt anspricht."
Eine Route ist keine Schranke gegen sich selbst. Sie setzte
weiterhin NUR den Zuteilungszustand und liess den
Aufgabenstatus stehen -- also genau den Widerspruch, den
VanVan gemeldet hat. Ich hatte ihn unsichtbar gemacht, nicht
abgeschafft: kein Knopf mehr, das Verhalten unveraendert im
System. Aufgefallen ist es beim Nachmessen am laufenden
Server, nicht beim Schreiben.
Die Route ist seither entfernt (siehe workspace-zuteilung.js).
Es gibt nur noch den Status -- eine Frage, eine Antwort. */
/* (2b) DIE BEWERBUNGEN — nur fuer die, die entscheiden duerfen. /* (2b) DIE BEWERBUNGEN — nur fuer die, die entscheiden duerfen.
+19 -19
View File
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -430,22 +430,22 @@
<!-- TEXTFORM ZUERST (21.09.2026): Die Seite fragt beim Zeichnen nach <!-- TEXTFORM ZUERST (21.09.2026): Die Seite fragt beim Zeichnen nach
`window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne `window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne
Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. --> Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. -->
<script src="assets/js/textform.js?v=202610021752"></script> <script src="assets/js/textform.js?v=202610060113"></script>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/hochladen.js?v=202610021752" defer></script> <script src="assets/js/hochladen.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/zuteilung.js?v=202610021752" defer></script> <script src="assets/js/zuteilung.js?v=202610060113" defer></script>
<!-- Der Vorlagenkatalog stand bis zum 23.09.2026 mitten in <!-- Der Vorlagenkatalog stand bis zum 23.09.2026 mitten in
aufgaben.js. Er bedient jetzt zwei Seiten und hat deshalb eine aufgaben.js. Er bedient jetzt zwei Seiten und hat deshalb eine
eigene Datei; hier zeigt er den Creator-Katalog. eigene Datei; hier zeigt er den Creator-Katalog.
Vor aufgaben.js, weil die Seite ihn beim Start einrichtet. --> Vor aufgaben.js, weil die Seite ihn beim Start einrichtet. -->
<script src="assets/js/vorlagenbrett.js?v=202610021752" defer></script> <script src="assets/js/vorlagenbrett.js?v=202610060113" defer></script>
<script src="assets/js/aufgaben.js?v=202610021752" defer></script> <script src="assets/js/aufgaben.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+16 -16
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/automation.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/automation.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -168,15 +168,15 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/automation.js?v=202610021752" defer></script> <script src="assets/js/automation.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+17 -17
View File
@@ -7,14 +7,14 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/entwicklung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -197,16 +197,16 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/holen.js?v=202610021752" defer></script> <script src="assets/js/holen.js?v=202610060113" defer></script>
<script src="assets/js/befinden.js?v=202610021752" defer></script> <script src="assets/js/befinden.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+36 -22
View File
@@ -12,24 +12,24 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/bereich.css?v=202610060113" />
<!-- Wegen des Blocks „Willst du dazugehoeren?" auf dem Brett <!-- Wegen des Blocks „Willst du dazugehoeren?" auf dem Brett
„Mitmachen". Eine eigene Kopie der Regel hier waere die zweite „Mitmachen". Eine eigene Kopie der Regel hier waere die zweite
Stelle, an der dieselbe Sache anders aussieht. --> Stelle, an der dieselbe Sache anders aussieht. -->
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/bewerbung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -128,6 +128,13 @@
<div id="feld-ende" hidden> <div id="feld-ende" hidden>
<label class="feld-schild" for="f-ende">Bis</label> <label class="feld-schild" for="f-ende">Bis</label>
<input id="f-ende" type="date" /> <input id="f-ende" type="date" />
<!-- WIE LANGE DAS LAEUFT, STEHT DARUNTER (03.10.2026).
"02.10. bis 15.10." liest man als Spanne, aber ob das zwei
Wochen sind, rechnet man im Kopf nach. Und ein Ende VOR dem
Anfang faellt hier auf statt erst auf der Karte - dort
stuende dann "vorbei seit 3 Tagen" an einem Event, das
gerade erst angelegt wurde. -->
<p class="feld-hinweis" id="hinweis-dauer" aria-live="polite"></p>
</div> </div>
<!-- ZU WELCHEM ACCOUNT GEHOERT DAS? (20.09.2026) <!-- ZU WELCHEM ACCOUNT GEHOERT DAS? (20.09.2026)
@@ -226,6 +233,13 @@
<textarea id="f-regeln" rows="5" maxlength="6000" <textarea id="f-regeln" rows="5" maxlength="6000"
placeholder="z. B.&#10;· Nur Streams auf dem eigenen Konto&#10;· Punkte zählen bis 23:59 am letzten Tag"></textarea> placeholder="z. B.&#10;· Nur Streams auf dem eigenen Konto&#10;· Punkte zählen bis 23:59 am letzten Tag"></textarea>
</div> </div>
<!-- DIE VORSCHAU (03.10.2026). Sie zeigt die Buehne mit DERSELBEN
Funktion, die sie spaeter auf dem Brett baut - eine nachgebaute
Vorschau waere eine zweite Fassung derselben Sache und zeigte
irgendwann etwas, das hinterher anders aussieht.
`hidden` von Anfang an: Ein leerer Kasten ueber einem leeren
Formular ist nur ein weiterer Kasten. -->
<section class="ev-vorschau" id="ev-vorschau" hidden aria-live="polite"></section>
<p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p> <p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p>
<div class="neu__knoepfe"> <div class="neu__knoepfe">
<button type="submit" class="knopf knopf--klein" id="neu-speichern">Eintragen</button> <button type="submit" class="knopf knopf--klein" id="neu-speichern">Eintragen</button>
@@ -304,19 +318,19 @@
<!-- TEXTFORM ZUERST (21.09.2026): Die Seite fragt beim Zeichnen nach <!-- TEXTFORM ZUERST (21.09.2026): Die Seite fragt beim Zeichnen nach
`window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne `window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne
Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. --> Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. -->
<script src="assets/js/textform.js?v=202610021752"></script> <script src="assets/js/textform.js?v=202610060113"></script>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/checkliste.js?v=202610021752" defer></script> <script src="assets/js/checkliste.js?v=202610060113" defer></script>
<script src="assets/js/ampel.js?v=202610021752" defer></script> <script src="assets/js/ampel.js?v=202610060113" defer></script>
<script src="assets/js/schulung.js?v=202610021752" defer></script> <script src="assets/js/schulung.js?v=202610060113" defer></script>
<script src="assets/js/holen.js?v=202610021752" defer></script> <script src="assets/js/holen.js?v=202610060113" defer></script>
<script src="assets/js/bereich.js?v=202610021752" defer></script> <script src="assets/js/bereich.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+19 -19
View File
@@ -7,17 +7,17 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/bereich.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/bereich.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/treff.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/treff.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/bewerbung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein - siehe treff-regeln.html. --> <!-- haus.css MUSS die letzte Stilvorlage sein - siehe treff-regeln.html. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -108,15 +108,15 @@
</main> </main>
<!-- Der Fuß gehört zu jeder Seite - siehe pruef-css-klassen. --> <!-- Der Fuß gehört zu jeder Seite - siehe pruef-css-klassen. -->
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/auskunft.js?v=202610021752" defer></script> <script src="assets/js/auskunft.js?v=202610060113" defer></script>
<script src="assets/js/bewerben.js?v=202610021752" defer></script> <script src="assets/js/bewerben.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+18 -18
View File
@@ -7,16 +7,16 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/entwicklung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/bewerbung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein - siehe treff-regeln.html. --> <!-- haus.css MUSS die letzte Stilvorlage sein - siehe treff-regeln.html. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -80,16 +80,16 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/auskunft.js?v=202610021752" defer></script> <script src="assets/js/auskunft.js?v=202610060113" defer></script>
<script src="assets/js/bewerbungen.js?v=202610021752" defer></script> <script src="assets/js/bewerbungen.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+3 -3
View File
@@ -59,11 +59,11 @@
<!-- DIE WERBEEINBLENDUNG. Dieselbe Datei wie in der Reaction-Seite: <!-- DIE WERBEEINBLENDUNG. Dieselbe Datei wie in der Reaction-Seite:
Zwei Fassungen altern unterschiedlich, und dann sieht der Stream Zwei Fassungen altern unterschiedlich, und dann sieht der Stream
anders aus als das, worueber geredet wird. --> anders aus als das, worueber geredet wird. -->
<link rel="stylesheet" href="assets/css/werbung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/werbung.css?v=202610060113" />
</head> </head>
<body class="obs-buehne"> <body class="obs-buehne">
<div id="platz"></div> <div id="platz"></div>
<script src="assets/js/werbung.js?v=202610021752" defer></script> <script src="assets/js/werbung.js?v=202610060113" defer></script>
<script src="assets/js/buehne.js?v=202610021752" defer></script> <script src="assets/js/buehne.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+17 -17
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/calls.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/calls.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -89,15 +89,15 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/ki.js?v=202610021752" defer></script> <script src="assets/js/ki.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/calls.js?v=202610021752" defer></script> <script src="assets/js/calls.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+20 -20
View File
@@ -12,26 +12,26 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog, <!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem .neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html. zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
Dialog zu sehen bekam. --> Dialog zu sehen bekam. -->
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/chat.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/chat.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -739,24 +739,24 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/bereiche.js?v=202610021752"></script> <script src="assets/js/bereiche.js?v=202610060113"></script>
<!-- TEXTFORM VOR ALLEM ANDEREN (21.09.2026): chat.js und bereich.js <!-- TEXTFORM VOR ALLEM ANDEREN (21.09.2026): chat.js und bereich.js
fragen beim Zeichnen nach `window.Textform`. Kaeme sie spaeter, fragen beim Zeichnen nach `window.Textform`. Kaeme sie spaeter,
waere die erste gezeichnete Nachricht ohne Gestaltung -- und das waere die erste gezeichnete Nachricht ohne Gestaltung -- und das
faellt nur dem auf, der genau hinsieht. KEIN `defer`, damit sie faellt nur dem auf, der genau hinsieht. KEIN `defer`, damit sie
wirklich vor den anderen dasteht. --> wirklich vor den anderen dasteht. -->
<script src="assets/js/textform.js?v=202610021752"></script> <script src="assets/js/textform.js?v=202610060113"></script>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/hochladen.js?v=202610021752" defer></script> <script src="assets/js/hochladen.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752"></script> <script src="assets/js/kopf.js?v=202610060113"></script>
<script src="assets/js/glocke.js?v=202610021752"></script> <script src="assets/js/glocke.js?v=202610060113"></script>
<!-- ANRUF VOR CHAT (18.09.2026): anruf.js stellt `window.anrufStarten` <!-- ANRUF VOR CHAT (18.09.2026): anruf.js stellt `window.anrufStarten`
bereit, und chat.js baut die Knoepfe nur, wenn es das gibt. bereit, und chat.js baut die Knoepfe nur, wenn es das gibt.
Andersherum waere die Reihenfolge eine stille Abhaengigkeit -- Andersherum waere die Reihenfolge eine stille Abhaengigkeit --
die Knoepfe fehlten, und niemand wuesste warum. --> die Knoepfe fehlten, und niemand wuesste warum. -->
<script src="assets/js/anruf.js?v=202610021752"></script> <script src="assets/js/anruf.js?v=202610060113"></script>
<!-- DIE BILDSCHAU (25.09.2026). Eigene Datei, weil sie nichts vom <!-- DIE BILDSCHAU (25.09.2026). Eigene Datei, weil sie nichts vom
Chat weiss: Sie bekommt einen Weg und einen Namen und zeigt ein Chat weiss: Sie bekommt einen Weg und einen Namen und zeigt ein
Bild. Jede andere Seite mit Fotos kann sie ebenso benutzen, ohne Bild. Jede andere Seite mit Fotos kann sie ebenso benutzen, ohne
@@ -767,8 +767,8 @@
sie beim ersten Foto noch nicht da -- und der erste Klick ginge sie beim ersten Foto noch nicht da -- und der erste Klick ginge
wie frueher in den Tab. Ein Fehler, der nur beim ERSTEN Mal wie frueher in den Tab. Ein Fehler, der nur beim ERSTEN Mal
auftritt, ist der, den man nie nachstellen kann. --> auftritt, ist der, den man nie nachstellen kann. -->
<script src="assets/js/bildschau.js?v=202610021752"></script> <script src="assets/js/bildschau.js?v=202610060113"></script>
<script src="assets/js/chat.js?v=202610021752"></script> <script src="assets/js/chat.js?v=202610060113"></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+17 -17
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/content.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/content.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -179,15 +179,15 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/checkliste.js?v=202610021752" defer></script> <script src="assets/js/checkliste.js?v=202610060113" defer></script>
<script src="assets/js/content.js?v=202610021752" defer></script> <script src="assets/js/content.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+6 -6
View File
@@ -36,15 +36,15 @@
<link rel="preload" as="image" href="assets/img/buehne/crew-gate-hoch-580.webp" <link rel="preload" as="image" href="assets/img/buehne/crew-gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" /> type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<!-- Diese Wand steht NUR auf der Adresse von Team Dogi (ueberall sonst <!-- Diese Wand steht NUR auf der Adresse von Team Dogi (ueberall sonst
404, siehe crewWeiche). Sie darf das Haus deshalb direkt nennen 404, siehe crewWeiche). Sie darf das Haus deshalb direkt nennen
statt ueber den Platzhalter haus.css zu gehen -- und sie MUSS es: statt ueber den Platzhalter haus.css zu gehen -- und sie MUSS es:
Eine Zugangswand, die sich erst nach dem Umbiegen faerbt, waere Eine Zugangswand, die sich erst nach dem Umbiegen faerbt, waere
die eine Seite, auf der man das Umfaerben sieht. --> die eine Seite, auf der man das Umfaerben sieht. -->
<link rel="stylesheet" href="assets/css/crew-haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/crew-haus.css?v=202610060113" />
</head> </head>
<body class="gate gate--crew"> <body class="gate gate--crew">
@@ -459,7 +459,7 @@
<script src="assets/js/gate.js?v=202610021752" defer></script> <script src="assets/js/gate.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -23,25 +23,25 @@
}, },
"icons": [ "icons": [
{ {
"src": "/assets/img/app-symbole/crew-192.png?v=202610021752", "src": "/assets/img/app-symbole/crew-192.png?v=202610060113",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/crew-512.png?v=202610021752", "src": "/assets/img/app-symbole/crew-512.png?v=202610060113",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202610021752", "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202610060113",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202610021752", "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202610060113",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+17 -17
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/dateien.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/dateien.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -108,15 +108,15 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/hochladen.js?v=202610021752" defer></script> <script src="assets/js/hochladen.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/dateien.js?v=202610021752" defer></script> <script src="assets/js/dateien.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+18 -18
View File
@@ -7,14 +7,14 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/entwicklung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -306,20 +306,20 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/holen.js?v=202610021752" defer></script> <script src="assets/js/holen.js?v=202610060113" defer></script>
<!-- VOR entwicklung.js: Die Seite richtet den Katalog beim Start ein, <!-- VOR entwicklung.js: Die Seite richtet den Katalog beim Start ein,
also muss es ihn zu dem Zeitpunkt geben. Mit "defer" laufen beide also muss es ihn zu dem Zeitpunkt geben. Mit "defer" laufen beide
in Dokumentreihenfolge. --> in Dokumentreihenfolge. -->
<script src="assets/js/vorlagenbrett.js?v=202610021752" defer></script> <script src="assets/js/vorlagenbrett.js?v=202610060113" defer></script>
<script src="assets/js/entwicklung.js?v=202610021752" defer></script> <script src="assets/js/entwicklung.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+15 -15
View File
@@ -7,13 +7,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/hilfe.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/hilfe.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -146,14 +146,14 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/hilfe.js?v=202610021752" defer></script> <script src="assets/js/hilfe.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+5 -5
View File
@@ -30,9 +30,9 @@
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp" <link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
type="image/webp" media="(max-width: 899px)" /> type="image/webp" media="(max-width: 899px)" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
</head> </head>
<body class="gate"> <body class="gate">
@@ -427,7 +427,7 @@
<script src="assets/js/gate.js?v=202610021752" defer></script> <script src="assets/js/gate.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+17 -17
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/kalender.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/kalender.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -383,15 +383,15 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/kalender.js?v=202610021752" defer></script> <script src="assets/js/kalender.js?v=202610060113" defer></script>
<script src="assets/js/kalender-abo.js?v=202610021752" defer></script> <script src="assets/js/kalender-abo.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+16 -16
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/leistung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/leistung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -444,13 +444,13 @@
</form> </form>
</dialog> </dialog>
<script src="assets/js/meldung.js?v=202610021752"></script> <script src="assets/js/meldung.js?v=202610060113"></script>
<script src="assets/js/nachfrage.js?v=202610021752"></script> <script src="assets/js/nachfrage.js?v=202610060113"></script>
<script src="assets/js/wahl.js?v=202610021752"></script> <script src="assets/js/wahl.js?v=202610060113"></script>
<script src="assets/js/bereiche.js?v=202610021752"></script> <script src="assets/js/bereiche.js?v=202610060113"></script>
<script src="assets/js/kopf.js?v=202610021752"></script> <script src="assets/js/kopf.js?v=202610060113"></script>
<script src="assets/js/glocke.js?v=202610021752"></script> <script src="assets/js/glocke.js?v=202610060113"></script>
<script src="assets/js/leistung.js?v=202610021752"></script> <script src="assets/js/leistung.js?v=202610060113"></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+14 -14
View File
@@ -7,17 +7,17 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/manager-ziele.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/manager-ziele.css?v=202610060113" />
<!-- module.css an VORLETZTER, haus.css an LETZTER Stelle. Die <!-- module.css an VORLETZTER, haus.css an LETZTER Stelle. Die
Reihenfolge ist keine Geschmacksfrage: haus.css setzt nur Reihenfolge ist keine Geschmacksfrage: haus.css setzt nur
Variablen, und bei Variablen entscheidet allein, wer zuletzt Variablen, und bei Variablen entscheidet allein, wer zuletzt
kommt. pruef-css-klassen.mjs misst genau dieses Paar nach. --> kommt. pruef-css-klassen.mjs misst genau dieses Paar nach. -->
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -218,16 +218,16 @@
kopf.js liest daraus die Leitungsrollen. Ohne sie stürzt jede kopf.js liest daraus die Leitungsrollen. Ohne sie stürzt jede
Seite ab, und zwar nur in der Browserkonsole -- die Kopfleiste Seite ab, und zwar nur in der Browserkonsole -- die Kopfleiste
kommt trotzdem. --> kommt trotzdem. -->
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<!-- wahl.js gehört zu kopf.js: Der Sicht-Umschalter baut daraus seine <!-- wahl.js gehört zu kopf.js: Der Sicht-Umschalter baut daraus seine
Liste. pruef-css-klassen setzt das durch, und zwar auch die Liste. pruef-css-klassen setzt das durch, und zwar auch die
Reihenfolge -- kopf.js fragt beim Aufbau nach dem fertigen Reihenfolge -- kopf.js fragt beim Aufbau nach dem fertigen
Bedienelement. --> Bedienelement. -->
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/manager-ziele.js?v=202610021752" defer></script> <script src="assets/js/manager-ziele.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+16 -16
View File
@@ -12,13 +12,13 @@
vergessen wird. --> vergessen wird. -->
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/material.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/material.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start" data-ton="42" data-buehne="studio"> <body class="start" data-ton="42" data-buehne="studio">
@@ -138,14 +138,14 @@
<p class="m-laedt" id="laedt">Einen Moment …</p> <p class="m-laedt" id="laedt">Einen Moment …</p>
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/hochladen.js?v=202610021752" defer></script> <script src="assets/js/hochladen.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/material.js?v=202610021752" defer></script> <script src="assets/js/material.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+14 -14
View File
@@ -7,13 +7,13 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/notizen.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/notizen.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start" data-ton="46"> <body class="start" data-ton="46">
@@ -181,12 +181,12 @@
<!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js <!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js
liest daraus die Leitungsrollen. --> liest daraus die Leitungsrollen. -->
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/notizen.js?v=202610021752" defer></script> <script src="assets/js/notizen.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+16 -16
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/personen.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/personen.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -453,14 +453,14 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/personen.js?v=202610021752" defer></script> <script src="assets/js/personen.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+18 -18
View File
@@ -12,20 +12,20 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. --> eine Flaeche sind statt zweier. -->
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/aufgaben.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/profil.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/profil.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026). <!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
Auf der Agenturadresse ist sie leer -- das Haus hier IST der Auf der Agenturadresse ist sie leer -- das Haus hier IST der
Grundzustand. Auf der Adresse von Team Dogi biegt der Server Grundzustand. Auf der Adresse von Team Dogi biegt der Server
genau diesen Dateinamen auf crew-haus.css um, und die Seite genau diesen Dateinamen auf crew-haus.css um, und die Seite
kommt schon beim ERSTEN Abruf in Lila und Babyblau an -- kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
ohne Umfaerben im Browser, ohne Flackern. --> ohne Umfaerben im Browser, ohne Flackern. -->
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start"> <body class="start">
@@ -214,16 +214,16 @@
</main> </main>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/hochladen.js?v=202610021752" defer></script> <script src="assets/js/hochladen.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/steckbrief.js?v=202610021752" defer></script> <script src="assets/js/steckbrief.js?v=202610060113" defer></script>
<script src="assets/js/profil.js?v=202610021752" defer></script> <script src="assets/js/profil.js?v=202610060113" defer></script>
</body> </body>
</html> </html>
+19 -19
View File
@@ -7,15 +7,15 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="manifest" href="/workspace/app.webmanifest" /> <link rel="manifest" href="/workspace/app.webmanifest" />
<meta name="theme-color" content="#06090f" /> <meta name="theme-color" content="#06090f" />
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" /> <link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610060113" />
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" /> <link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610060113" />
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/gate.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/start.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/reaktion.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/reaktion.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/spendenkarte.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/spendenkarte.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/werbung.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/werbung.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/module.css?v=202610060113" />
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" /> <link rel="stylesheet" href="assets/css/haus.css?v=202610060113" />
</head> </head>
<body class="start reaktion-seite" data-stand="laedt"> <body class="start reaktion-seite" data-stand="laedt">
@@ -905,15 +905,15 @@
<!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js <!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js
liest daraus die Leitungsrollen. --> liest daraus die Leitungsrollen. -->
<script src="assets/js/bereiche.js?v=202610021752" defer></script> <script src="assets/js/bereiche.js?v=202610060113" defer></script>
<script src="assets/js/wahl.js?v=202610021752" defer></script> <script src="assets/js/wahl.js?v=202610060113" defer></script>
<script src="assets/js/nachfrage.js?v=202610021752" defer></script> <script src="assets/js/nachfrage.js?v=202610060113" defer></script>
<script src="assets/js/meldung.js?v=202610021752" defer></script> <script src="assets/js/meldung.js?v=202610060113" defer></script>
<script src="assets/js/installieren.js?v=202610021752" defer></script> <script src="assets/js/installieren.js?v=202610060113" defer></script>
<script src="assets/js/kopf.js?v=202610021752" defer></script> <script src="assets/js/kopf.js?v=202610060113" defer></script>
<script src="assets/js/glocke.js?v=202610021752" defer></script> <script src="assets/js/glocke.js?v=202610060113" defer></script>
<script src="assets/js/spendenkarte.js?v=202610021752" defer></script> <script src="assets/js/spendenkarte.js?v=202610060113" defer></script>
<script src="assets/js/werbung.js?v=202610021752" defer></script> <script src="assets/js/werbung.js?v=202610060113" defer></script>
<script src="assets/js/reaktion.js?v=202610021752" defer></script> <script src="assets/js/reaktion.js?v=202610060113" defer></script>
</body> </body>
</html> </html>

Some files were not shown because too many files have changed in this diff Show More