Compare commits
33
Commits
72e51c57b6
..
main
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -66,7 +66,7 @@ import { ampelRouter } from "./workspace-ampel.js";
|
||||
import { checklisteRouter } from "./workspace-checkliste.js";
|
||||
import { teamlageRouter } from "./workspace-teamlage.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 { videoRouter, videoWacheStarten } from "./workspace-video.js";
|
||||
import { bewerbungRouter } from "./workspace-bewerbung.js";
|
||||
@@ -370,6 +370,10 @@ app.use(contentRouter);
|
||||
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
|
||||
sicherungStarten();
|
||||
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
|
||||
sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein
|
||||
Knopf -- man klickt ihn ja. */
|
||||
|
||||
@@ -77,15 +77,21 @@ const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAGQAAADwCAYAAAB7ovsMAAAAWklEQVR42u3QMQEAAAjAoNu/"
|
||||
+ "tBmeIAfpVoAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAAAQIECBAgQIAA"
|
||||
+ "AQIECBAgQIAAAQIECBAgQIAAAe4W1H8B4aXvOgAAAABJRU5ErkJggg==", "base64");
|
||||
d.prepare(`INSERT INTO support_meldungen
|
||||
(person_id, text, seite, bild_datei, bild_typ, bild_breite, bild_hoehe,
|
||||
stand, erstellt, geaendert)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
.run(idModi,
|
||||
/* DIE BILDER STEHEN IN `support_bilder` (ab 02.10.2026), nicht mehr
|
||||
in Spalten der Meldung. Hier direkt dort eintragen und NICHT auf
|
||||
die Uebernahme beim Start hoffen: Die ist schon gelaufen, der
|
||||
Server steht oben in dieser Datei laengst. */
|
||||
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 "
|
||||
+ "damit ich wieder auf die Hauptseite kam",
|
||||
"/workspace/support.html", "probe.png", "image/png", 100, 240,
|
||||
"in_arbeit", jetzt, jetzt);
|
||||
"/workspace/support.html", "in_arbeit", jetzt, jetzt).id;
|
||||
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
|
||||
(person_id, text, seite, stand, erstellt, geaendert)
|
||||
VALUES (?,?,?,?,?,?)`)
|
||||
@@ -95,7 +101,10 @@ d.prepare(`INSERT INTO support_meldungen
|
||||
const { DATEN_ORDNER } = await import("./workspace.js");
|
||||
try {
|
||||
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 });
|
||||
writeFileSync(join(ziel, "probe.png"), png);
|
||||
} catch (f) { console.log("Bild nicht abgelegt:", f?.message); }
|
||||
|
||||
@@ -118,6 +118,41 @@ const anfrage = (pfad, art, koerper, keks) => new Promise((fertig, schief) => {
|
||||
if (koerper) a.write(JSON.stringify(koerper));
|
||||
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 an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code });
|
||||
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);
|
||||
|
||||
/* --- Runde 2: „noch nicht", dann behoben, dann bestaetigt --------- */
|
||||
await anfrage(`/workspace/api/support/${dran}/rueckmeldung`, "POST",
|
||||
{ geht: false, text: "Der Knopf ja – aber jetzt springt die Seite nach oben." }, kModi);
|
||||
const r2 = await rueckmelden(dran, false,
|
||||
"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",
|
||||
{ stand: "erledigt", antwort: "Das Springen kam vom Nachladen. Ist raus." }, kHand);
|
||||
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 –`
|
||||
+ " so sieht man beide Sorten nebeneinander.)");
|
||||
} finally {
|
||||
|
||||
@@ -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);
|
||||
@@ -697,33 +697,56 @@ melde("\n=== Die Kachel ===");
|
||||
Regeln koennen vollstaendig in der Antwort stehen und trotzdem
|
||||
nirgends gezeichnet werden -- ein vergessener Aufruf in karte()
|
||||
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 k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion");
|
||||
if (!k) return null;
|
||||
return {
|
||||
stand: k.dataset.event,
|
||||
marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(),
|
||||
spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(),
|
||||
schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()),
|
||||
texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()),
|
||||
marke: k.querySelector(".ev-noch")?.textContent.trim(),
|
||||
spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
|
||||
schilder: [...k.querySelectorAll(".ev-block__schild")].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?.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}")`);
|
||||
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
|
||||
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
|
||||
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
|
||||
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
|
||||
haben. Sonst bewiese der Block oben nur, dass die Elemente
|
||||
existieren -- nicht, dass sie zur richtigen Art gehoeren. */
|
||||
const schulung = await seite.$$eval(".eintrag-karte", (karten) => {
|
||||
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,
|
||||
`eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`);
|
||||
|
||||
@@ -63,6 +63,24 @@ import { fileURLToPath } from "node:url";
|
||||
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
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;
|
||||
const pruef = (name, bedingung, zusatz = "") => {
|
||||
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
|
||||
===================================================================== */
|
||||
const krypto = lies("server/workspace-push-krypto.js");
|
||||
const versand = lies("server/workspace-push.js");
|
||||
const krypto = nurCode(lies("server/workspace-push-krypto.js"));
|
||||
const versand = nurCode(lies("server/workspace-push.js"));
|
||||
|
||||
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\"");
|
||||
|
||||
pruef("ein dringender Push geht mit Urgency: high hinaus",
|
||||
/Urgency:\s*dringend\s*\?\s*"high"\s*:\s*"normal"/.test(krypto));
|
||||
pruef("ein eiliger Push geht mit Urgency: high hinaus",
|
||||
/Urgency:\s*eilig\s*\?\s*"high"\s*:\s*"normal"/.test(krypto));
|
||||
|
||||
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");
|
||||
|
||||
pruef("der Anruf wird als dringend verschickt",
|
||||
/schicken\(a,\s*inhalt,\s*paar,\s*ABSENDER,\s*\{\s*dringend\s*\}\)/.test(versand));
|
||||
pruef("der Anruf wird eilig UND kurzlebig verschickt",
|
||||
/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?"
|
||||
und „darf das warten?" haben dieselbe Antwort -- zwei getrennte
|
||||
Bedingungen wären die, die beim nächsten Nachschärfen auseinander
|
||||
laufen. */
|
||||
pruef("und zwar an derselben Bedingung wie die Nachtruhe",
|
||||
/const dringend = art === "test" \|\| art === "anruf";/.test(versand));
|
||||
/* ---- ZWEI FRAGEN, ZWEI FELDER (geändert 03.10.2026) -----------------
|
||||
|
||||
Hier stand bis heute das Gegenteil: „Darf das nachts stören?" und
|
||||
„darf das warten?" hätten dieselbe Antwort, zwei getrennte
|
||||
Bedingungen wären die, die auseinanderlaufen. Die Vorhersage war
|
||||
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?
|
||||
|
||||
@@ -75,6 +75,9 @@ const keks = async (rolle, code) => {
|
||||
};
|
||||
const kScout = await keks("scout", "CODE-SCOU-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 senden = (pfad, k, art, koerper) => ruf(pfad, k, {
|
||||
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})`);
|
||||
}
|
||||
|
||||
/* ---------- 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 ------------------------------------------------ */
|
||||
console.log("\n=== Gegenproben");
|
||||
{
|
||||
@@ -416,6 +532,92 @@ console.log("\n=== Auf der Aufgabenseite");
|
||||
`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
|
||||
holt, holt meistens mehrere. */
|
||||
await seite.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
@@ -848,8 +848,223 @@ melde("=== 6. Am echten Bildschirm ===");
|
||||
await kasten2.screenshot({ path: "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(); }
|
||||
|
||||
/* 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);
|
||||
try {
|
||||
await h2.s.goto(`${SEITE}/workspace/entwicklung.html`, { waitUntil: "networkidle" });
|
||||
@@ -1017,17 +1232,22 @@ melde("=== 6. Am echten Bildschirm ===");
|
||||
/* DER KERN DER MELDUNG. */
|
||||
await roh(`/workspace/api/aufgaben/${dId}/annehmen`,
|
||||
{ methode: "POST", keks: modi.keks });
|
||||
const fertig = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, {
|
||||
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } });
|
||||
/* UEBER DEN STATUS, nicht ueber `/mein-stand` (03.10.2026).
|
||||
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",
|
||||
`Frida kann sie nicht abhaken (${fertig.code}/${fertig.json?.fehler})`);
|
||||
|
||||
/* GEGENPROBE 1: „Ich fange an" geht weiterhin. Auch eine
|
||||
stehende Aufgabe hat einen Anfang -- gesperrt ist nur das
|
||||
Abhaken, nicht die Arbeit. */
|
||||
const anfang = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, {
|
||||
methode: "POST", keks: modi.keks, body: { zustand: "arbeit" } });
|
||||
ok(anfang.code === 200, `„Ich fange an" geht trotzdem (${anfang.code})`);
|
||||
const anfang = await roh(`/workspace/api/aufgaben/${dId}`, {
|
||||
methode: "PATCH", keks: modi.keks, body: { status: "arbeit" } });
|
||||
ok(anfang.code === 200, `anfangen geht trotzdem (${anfang.code})`);
|
||||
|
||||
/* GEGENPROBE 2: Bei einer GEWOEHNLICHEN Aufgabe geht das Abhaken.
|
||||
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;
|
||||
await roh(`/workspace/api/aufgaben/${nId}/annehmen`,
|
||||
{ methode: "POST", keks: modi.keks });
|
||||
const ok2 = await roh(`/workspace/api/aufgaben/${nId}/mein-stand`, {
|
||||
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } });
|
||||
const ok2 = await roh(`/workspace/api/aufgaben/${nId}`, {
|
||||
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
|
||||
ok(ok2.code === 200,
|
||||
`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);
|
||||
ok(beenden.code === 200 && !a3?.dauerhaft,
|
||||
`die rechte Hand beendet sie (${beenden.code}, danach ${a3?.dauerhaft})`);
|
||||
const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}/mein-stand`, {
|
||||
methode: "POST", keks: modi.keks, body: { zustand: "erledigt" } });
|
||||
const jetztFertig = await roh(`/workspace/api/aufgaben/${dId}`, {
|
||||
methode: "PATCH", keks: modi.keks, body: { status: "erledigt" } });
|
||||
ok(jetztFertig.code === 200,
|
||||
`und danach laesst sie sich abhaken (${jetztFertig.code})`);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-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;
|
||||
/* 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++; };
|
||||
|
||||
/* ---- Bestand ------------------------------------------------------------ */
|
||||
@@ -1063,6 +1068,350 @@ async function tonAufnehmen(seite, ms) {
|
||||
ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
|
||||
` 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 ------------------ */
|
||||
const vorschau = await dogi.seite.evaluate(async () => {
|
||||
const a = await fetch("/workspace/api/chat/raeume");
|
||||
@@ -1453,6 +1802,7 @@ await dogi.ctx.close();
|
||||
await browser.close();
|
||||
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`);
|
||||
process.exit(fehler ? 1 : 0);
|
||||
|
||||
@@ -1065,6 +1065,157 @@ melde("\n=== Wer hat reagiert? ===");
|
||||
" 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();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
|
||||
@@ -313,6 +313,121 @@ console.log("\n=== Ungelesene Nachrichten ===");
|
||||
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
|
||||
======================================================================= */
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -512,42 +512,40 @@ melde("\n=== Anmelden und wieder abmelden ===");
|
||||
const zahl = () => p.prepare("SELECT COUNT(*) AS n FROM push_anmeldungen").get().n;
|
||||
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
|
||||
hatten SIEBEN kein einziges Geraet angemeldet. Bei ihnen kommt
|
||||
nichts an, solange die Seite zu ist -- keine Aufgabe, kein Termin,
|
||||
kein Anruf.
|
||||
Bis heute stand dieser Hinweis in "Was ist dran?", eingefuehrt am
|
||||
19.09., als sieben von elf Zugaengen kein Geraet hatten. Er hat
|
||||
das Problem nicht geloest: Am 03.10. nachgemessen waren es immer
|
||||
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:
|
||||
Sie ist ein stiller Schalter in einer Leiste. Deshalb steht es
|
||||
jetzt auch dort, wo man hinsieht -- in "Was ist dran?".
|
||||
Ersetzt durch einen Kasten mit "Anschalten"-Knopf, direkt ueber
|
||||
dieser Liste (gemessen in pruef-push-hinweis.mjs, 16 Pruefungen).
|
||||
|
||||
DIE GEGENPROBE IST DER EIGENTLICHE PUNKT: Der Hinweis muss
|
||||
VERSCHWINDEN, sobald ein Geraet da ist. Eine Warnung, die immer
|
||||
kommt, ist keine Warnung mehr -- und bei sieben von elf Leuten
|
||||
stuende sie sonst dauerhaft ueber den echten. */
|
||||
WAS HIER JETZT GEPRUEFT WIRD, ist das Gegenteil von vorher, und
|
||||
zwar aus einem Grund: Aufgefallen ist die Dopplung erst auf einem
|
||||
Bildschirmfoto -- beide Saetze standen untereinander auf
|
||||
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 a = await fetch("/workspace/api/hinweise", { credentials: "same-origin" });
|
||||
return a.json();
|
||||
})).hinweise || [];
|
||||
|
||||
{
|
||||
const vorher = await hinweise();
|
||||
const h = vorher.find((x) => x.art === "kein_geraet");
|
||||
ok(!!h, "ohne Geraet steht der Hinweis in „Was ist dran?“");
|
||||
ok(h?.ziel === "anruf-probe.html",
|
||||
`und er fuehrt auf die Seite, die sagt warum (${h?.ziel})`);
|
||||
ok(h?.stufe === "offen",
|
||||
`als offener Punkt, nicht als Warnung (${h?.stufe}) — sonst stuende er `
|
||||
+ "dauerhaft ueber den echten Warnungen");
|
||||
/* Und auf dem Bildschirm steht er ohne die "1" davor: Er zaehlt
|
||||
nichts, er beschreibt einen Zustand. */
|
||||
const sichtbar = await seite.evaluate(() =>
|
||||
const offen = await hinweise();
|
||||
/* Die Zahl gehoert in die Bedingung: Kaeme die Schnittstelle gar
|
||||
nicht zustande, waere die Liste leer -- und "kein_geraet steht
|
||||
nicht drin" waere gruen, ohne etwas gesehen zu haben. */
|
||||
ok(Array.isArray(offen) && !offen.some((x) => x.art === "kein_geraet"),
|
||||
`"Bei dir klingelt nichts" steht nicht mehr in der Liste `
|
||||
+ `(${offen.length} Hinweise geprueft) -- es stuende sonst zweimal auf einem Schirm`);
|
||||
const aufDerSeite = await seite.evaluate(() =>
|
||||
[...document.querySelectorAll(".dran__punkt")]
|
||||
.map((li) => li.textContent.trim()).find((t) => /klingelt nichts/.test(t)) || "");
|
||||
ok(/klingelt nichts/.test(sichtbar) && !/^\s*1/.test(sichtbar),
|
||||
`auf der Seite steht er ohne Zahl davor: „${sichtbar.replace(/\s+/g, " ").slice(0, 60)}“`);
|
||||
.filter((li) => /klingelt nichts/.test(li.textContent)).length);
|
||||
ok(aufDerSeite === 0, `und auch nicht auf der Seite (${aufDerSeite})`);
|
||||
}
|
||||
|
||||
await seite.click("#glocke");
|
||||
@@ -555,17 +553,17 @@ melde("\n=== Anmelden und wieder abmelden ===");
|
||||
|
||||
ok(zahl() === 1, `nach dem Klick: ${zahl()} Anmeldung`);
|
||||
|
||||
/* GEGENPROBE: Jetzt muss der Hinweis weg sein. */
|
||||
{
|
||||
/* OHNE NEULADEN. Der erste Anlauf lud die Seite neu -- danach war
|
||||
das Test-Doppel fuer den Push-Dienst weg, der Glockenknopf stand
|
||||
wieder auf "aus", und die drei folgenden Pruefungen meldeten
|
||||
Fehler, die es nicht gab. Die Schnittstelle antwortet ohnehin
|
||||
live; eine neue Seite braucht es dafuer nicht. */
|
||||
const nachher = await hinweise();
|
||||
ok(!nachher.some((x) => x.art === "kein_geraet"),
|
||||
"mit einem angemeldeten Geraet ist der Hinweis weg — nichts zu quittieren");
|
||||
}
|
||||
/* Hier stand bis zum 03.10.2026 die Gegenprobe "mit angemeldetem
|
||||
Geraet ist der Hinweis weg". Sie ist ersatzlos entfallen -- nicht
|
||||
aus Bequemlichkeit, sondern weil sie seit dem Entfernen des
|
||||
Hinweises IMMER gruen waere, egal was das Haus tut. Eine
|
||||
Pruefung, die nichts mehr widerlegen kann, bestaetigt nichts;
|
||||
sie taeuscht nur Deckung vor.
|
||||
|
||||
Was sie geprueft hat ("verschwindet von selbst, sobald ein Geraet
|
||||
da ist"), prueft jetzt pruef-push-hinweis.mjs am neuen Kasten --
|
||||
dort mit einer echten Anmeldung, die nachweislich in der
|
||||
Datenbank landet. */
|
||||
|
||||
const eintrag = p.prepare("SELECT * FROM push_anmeldungen").get();
|
||||
ok(eintrag?.person_id === idDogi && /^https:\/\//.test(eintrag?.endpunkt || ""),
|
||||
|
||||
@@ -599,6 +599,28 @@ const ROLLEN = [
|
||||
];
|
||||
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;
|
||||
const probleme = [];
|
||||
const melde = (t) => console.log(t);
|
||||
@@ -634,14 +656,7 @@ for (const [breite, hoehe, geraet] of BREITEN) {
|
||||
/* ---- Anmelden, wie ein Mensch es tut ---------------------------- */
|
||||
let drin = false;
|
||||
try {
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded", timeout: 20000 });
|
||||
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 });
|
||||
await anmeldenAufCrew(seite, rolle, code);
|
||||
drin = true;
|
||||
} catch (f) {
|
||||
/* EINE GESCHEITERTE ANMELDUNG IST DER SCHLIMMSTE BEFUND -- danach
|
||||
@@ -864,9 +879,86 @@ melde("\n --- Gegenprobe (beide Fehler MÜSSEN gefunden werden) ---");
|
||||
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();
|
||||
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(` ${geprueft} Seitenaufrufe, ${elementeGesamt} Elemente, ${bedienbarGesamt} Bedienelemente gemessen`);
|
||||
melde(` ${fehler} Befund${fehler === 1 ? "" : "e"}`);
|
||||
|
||||
@@ -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);
|
||||
@@ -996,6 +996,99 @@ console.log("=== Haengt es wirklich an der Adresse? ===");
|
||||
+ `(${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();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
console.log("");
|
||||
|
||||
@@ -945,6 +945,105 @@ console.log("");
|
||||
ok(danach.markiert >= 1,
|
||||
"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) ======
|
||||
|
||||
Filipe: „nur dogfather und die rechte hand sollen annehmen oder
|
||||
|
||||
@@ -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 ----------------------------------------
|
||||
Sie steht ABSICHTLICH GANZ UNTEN: Sie haengt eine Person
|
||||
absichtlich aus der Regel aus. Lief sie weiter oben, tauchte
|
||||
|
||||
+220
-9
@@ -756,12 +756,17 @@ if (process.env.OHNE_BROWSER === "ja") {
|
||||
});
|
||||
await seite.waitForTimeout(400);
|
||||
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 {
|
||||
name: document.querySelector('dialog.nachfrage [data-teil="bildname"]')
|
||||
?.textContent || "",
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -796,7 +801,7 @@ if (process.env.OHNE_BROWSER === "ja") {
|
||||
feld.files = dt.files;
|
||||
feld.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
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 ja = document.querySelector('dialog.nachfrage [data-teil="ja"]');
|
||||
const r = schau.getBoundingClientRect();
|
||||
@@ -813,9 +818,14 @@ if (process.env.OHNE_BROWSER === "ja") {
|
||||
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`
|
||||
+ ` (echt ${hoch.echt}, höchstens 160)`,
|
||||
hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 160);
|
||||
+ ` (echt ${hoch.echt}, höchstens 120)`,
|
||||
hoch.echt === 900 && hoch.gezeigt > 0 && hoch.gezeigt <= 120);
|
||||
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)`,
|
||||
hoch.jaImFenster);
|
||||
@@ -825,15 +835,23 @@ if (process.env.OHNE_BROWSER === "ja") {
|
||||
await seite.waitForTimeout(500);
|
||||
const erg = await seite.evaluate(async () => {
|
||||
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 {
|
||||
grund: a && a.grund,
|
||||
bildName: a && a.bild ? a.bild.name : null,
|
||||
bildTyp: a && a.bild ? a.bild.type : null,
|
||||
istDatei: !!(a && a.bild && typeof a.bild.size === "number"),
|
||||
anzahl: a && Array.isArray(a.bilder) ? a.bilder.length : -1,
|
||||
bildName: eins ? eins.name : null,
|
||||
bildTyp: eins ? eins.type : null,
|
||||
istDatei: !!(eins && typeof eins.size === "number"),
|
||||
};
|
||||
});
|
||||
pruef(`der Dialog gibt den Grund zurück („${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})`,
|
||||
/* und nicht : Zwei Zeilen
|
||||
weiter oben wurde das Bild ERSETZT. Meine erste Fassung
|
||||
@@ -875,9 +893,202 @@ if (process.env.OHNE_BROWSER === "ja") {
|
||||
/Noch keins/.test(frisch));
|
||||
await seite.click('dialog.nachfrage [data-teil="nein"]');
|
||||
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();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
|
||||
@@ -71,6 +71,18 @@ anlegen("Patrick", "scout", "CODE-PATR-0001");
|
||||
anlegen("SpongBobSchwammKopf", "creator", "CODE-SPON-0001");
|
||||
anlegen("Tili", "creator", "CODE-TILI-0001");
|
||||
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();
|
||||
|
||||
const { chromium } = await import(
|
||||
@@ -91,6 +103,13 @@ async function seiteFuer(kontext) {
|
||||
|
||||
const lesen = (seite) => seite.evaluate(() => ({
|
||||
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) => ({
|
||||
name: g.querySelector(".gruppe__name")?.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}")`);
|
||||
}
|
||||
|
||||
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");
|
||||
{
|
||||
/* Nur die Creator zuklappen. Es muss die EINZELNE Einstellung
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -84,9 +84,50 @@ for (const datei of serverDateien) {
|
||||
Die Erfassung der AUFRUFE kannte alle drei Zeichen schon
|
||||
(["'`]) -- nur die Erfassung der ROUTEN nicht. Zwei Regeln fuer
|
||||
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(
|
||||
/Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*["'`](\/workspace\/[^"'`]*)/g);
|
||||
for (const t of treffer) ROUTEN.push({ art: t[1], pfad: t[2], datei });
|
||||
/Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*["'`](\$\{[A-Za-z_$][\w$]*\})?(\/workspace\/[^"'`]*|\/[^"'`]*)/g);
|
||||
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`);
|
||||
|
||||
@@ -209,6 +250,37 @@ for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
|
||||
}
|
||||
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++;
|
||||
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")),
|
||||
"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?
|
||||
|
||||
|
||||
@@ -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
@@ -158,6 +158,20 @@ const PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
|
||||
+ "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 = {};
|
||||
melde("\n=== Alle neun Rollen kommen herein ===");
|
||||
for (const [rolle] of ROLLEN) {
|
||||
@@ -279,6 +293,7 @@ melde("\n=== Die Seite ist fuer jede Rolle offen ===");
|
||||
===================================================================== */
|
||||
melde("\n=== Melden ===");
|
||||
let meldungMitBild = 0;
|
||||
let bildWeg = "";
|
||||
{
|
||||
/* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */
|
||||
const leer = await hol("creator", "/workspace/api/support", {
|
||||
@@ -305,8 +320,16 @@ let meldungMitBild = 0;
|
||||
if (mitBild.status === 201) {
|
||||
const j = await mitBild.json();
|
||||
meldungMitBild = j.meldung?.id || 0;
|
||||
ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes");
|
||||
} else { ok(false, "kein Bild in der Antwort"); }
|
||||
bildWeg = j.meldung?.bilder?.[0]?.weg || "";
|
||||
/* 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.
|
||||
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. */
|
||||
const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`);
|
||||
const fremd = await hol("creator", bildWeg);
|
||||
ok(fremd.status === 404,
|
||||
`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.headers.get("content-type") === "image/png",
|
||||
`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") || ""),
|
||||
"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");
|
||||
|
||||
/* ==== 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 r1 = (stand?.verlauf || [])[0];
|
||||
ok(!!r1?.urteil_bild,
|
||||
`im Verlauf haengt das Bild an Runde ${r1?.nr} (${r1?.urteil_bild})`);
|
||||
const r1weg = r1?.bilder?.[0]?.weg || "";
|
||||
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
|
||||
Runden nicht mehr zu sehen, welches Bild zu welchem Versuch
|
||||
gehoerte. */
|
||||
ok(/\/runde\/1\/bild$/.test(r1?.urteil_bild || ""),
|
||||
" und zwar an DIESER Runde, nicht oben an der Meldung");
|
||||
gehoerte.
|
||||
SEIT DEM 02.10. STEHT DAS NICHT MEHR IM WEG (die Adresse nennt
|
||||
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(/springt sie nach oben/.test(r1?.urteil_text || ""),
|
||||
` und der Text kam durch den Kopf heil an („${r1?.urteil_text}")`);
|
||||
|
||||
/* ---- 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());
|
||||
ok(holen.status === 200 && bytes.length === PNG.length,
|
||||
`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",
|
||||
"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,
|
||||
`die Leitung sieht es auch (${auchLeitung.status}) — sie soll ja darauf schauen`);
|
||||
|
||||
/* ---- GEGENPROBEN ---------------------------------------------- */
|
||||
const fremder = await hol("gast", r1.urteil_bild);
|
||||
const fremder = await hol("gast", r1weg);
|
||||
ok(fremder.status === 404,
|
||||
`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,
|
||||
`ohne Anmeldung gar nichts (${ohneAnmeldung.status})`);
|
||||
|
||||
const gibtsNicht = await hol("modi", `/workspace/api/support/${neuId}/runde/9/bild`);
|
||||
ok(gibtsNicht.status === 404, `eine Runde ohne Bild gibt 404 (${gibtsNicht.status})`);
|
||||
/* EINE BILDNUMMER, DIE ES NICHT GIBT. Vorher stand hier
|
||||
„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,
|
||||
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?
|
||||
===================================================================== */
|
||||
@@ -680,7 +1043,7 @@ melde("\n=== Gegenproben (jede MUSS anschlagen) ===");
|
||||
`ohne Anmeldung keine Liste (${ohne.status})`);
|
||||
|
||||
/* 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})`);
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -892,8 +892,30 @@ melde("=== 6. Schreiben ===");
|
||||
text: "Ansage", datum: heuteLokal() });
|
||||
ok(brettDesTeams.code === 403, `aufs Anschlagbrett schreibt nur das Team (${brettDesTeams.code})`);
|
||||
|
||||
const beiDogi = await brett("treff", HAUS, kDogi);
|
||||
ok(beiDogi.titel.includes("GAST-SCHREIBT-8899"), "DogFather sieht den Beitrag");
|
||||
/* ==== AUF DER CREW-ADRESSE, NICHT AUF DER AGENTURADRESSE ========
|
||||
(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);
|
||||
ok(beiModi.titel.includes("GAST-SCHREIBT-8899"), "der Modi auch");
|
||||
const beiCrea = await brett("treff", HAUS, kCrea);
|
||||
|
||||
+125
-3
@@ -79,7 +79,7 @@ melde("\n=== 1. Die Regel ===");
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
||||
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 k = randomBytes(32).toString("hex");
|
||||
@@ -97,6 +97,10 @@ let idDogi, idHand, idModi, idModi2;
|
||||
idHand = anlegen("VanVan", "hand", "CODE-H");
|
||||
idModi = anlegen("Diene", "modi", "CODE-M");
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -132,6 +136,7 @@ melde("\n=== 2. Durch die Schnittstelle ===");
|
||||
const kDogi = await anmelden("admin", "CODE-A");
|
||||
const kHand = await anmelden("hand", "CODE-H");
|
||||
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");
|
||||
|
||||
/* 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" });
|
||||
const id3 = json(fremd).id;
|
||||
const handFremd = await ruf(`/workspace/api/aufgaben/${id3}`, kHand, "DELETE");
|
||||
ok(fremd.code === 201 && handFremd.code === 403,
|
||||
`Gegenprobe: eine fremde Aufgabe loescht sie nicht (${handFremd.code})`);
|
||||
ok(fremd.code === 201 && handFremd.code === 200,
|
||||
`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`);
|
||||
|
||||
@@ -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);
|
||||
@@ -436,6 +436,66 @@ melde("\n=== Fertig machen und bewerten ===");
|
||||
ok(fremderStatus.code === 403 || fremderStatus.code === 404,
|
||||
` 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.
|
||||
|
||||
Dieser Abschnitt hat Annas Aufgabe auf „arbeit" gestellt. Ein
|
||||
@@ -453,14 +513,40 @@ melde("\n=== Fertig machen und bewerten ===");
|
||||
ok(wiederDa === "angenommen",
|
||||
` zurueckgedreht steht Anna wieder auf „angenommen" (${wiederDa})`);
|
||||
|
||||
const arbeit = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea,
|
||||
body: { zustand: "arbeit" } });
|
||||
/* DER WEG BLEIBT BESTEHEN, auch ohne Knopf: Er ist die Schranke
|
||||
fuer den, der die Schnittstelle direkt anspricht. */
|
||||
/* ==== BEIDE ZUSTAENDE, NICHT NUR HTTP 200 (03.10.2026) ==========
|
||||
|
||||
Hier stand der Weg ueber `/mein-stand` und daneben nur die
|
||||
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})`);
|
||||
const fertig = await rufe(`/workspace/api/aufgaben/${idPool}/mein-stand`, { method: "POST", keks: bea,
|
||||
body: { zustand: "erledigt" } });
|
||||
ok(beideArbeit.status === "arbeit" && beideArbeit.zustand === "arbeit",
|
||||
` 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(beideFertig.status === "erledigt" && beideFertig.zustand === "erledigt",
|
||||
` und wieder beides (Aufgabe ${beideFertig.status}, Zuteilung ${beideFertig.zustand})`);
|
||||
|
||||
/* DIE DREI RUECKMELDUNGEN AUS ABSCHNITT 5. */
|
||||
const leer = await rufe(`/workspace/api/aufgaben/${idPool}/bewerten`, { method: "POST", keks: chef,
|
||||
|
||||
@@ -252,8 +252,55 @@ export function supportTabellen(d) {
|
||||
ON support_meldungen(stand, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_support_person
|
||||
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 ============
|
||||
(02.10.2026)
|
||||
|
||||
@@ -290,4 +337,53 @@ export function supportTabellen(d) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import express from "express";
|
||||
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,
|
||||
MODI_KATEGORIEN, kategorienFuer, kategoriePersonen,
|
||||
KANAELE, kanaeleFuer,
|
||||
@@ -374,6 +374,38 @@ function darfAendern(person, aufgabe) {
|
||||
ohnehin. Die Zeile ist damit eine Erweiterung, keine Einschraenkung
|
||||
-- nachgewiesen in pruef-aufgaben-loeschen mit einer Gegenprobe. */
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -544,6 +576,12 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => {
|
||||
Recht -- vorher hing sie am falschen, und eine Modi sah
|
||||
gar keinen. */
|
||||
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.
|
||||
Bisher entschied sie es an `darf_verteilen` -- einer Auskunft
|
||||
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 });
|
||||
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. */
|
||||
if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.verantwortlich_id; }
|
||||
|
||||
|
||||
+424
-10
@@ -20,7 +20,7 @@ import express from "express";
|
||||
Fassung, und an ihr haengt die Sicherheit aller Uploads im Haus. */
|
||||
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { dateiErkennen } from "./workspace-chat.js";
|
||||
import { TREFF_START, VORSCHLAG_HINWEIS } from "./workspace-treff-start.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,
|
||||
externPruefen, externSql,
|
||||
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,
|
||||
gehoertAufDieseAdresse,
|
||||
bereicheFuer, kanaeleFuer, KANAELE,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
import { nachrichtSchicken } from "./workspace-chat.js";
|
||||
import {
|
||||
@@ -752,6 +753,89 @@ const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
|
||||
const TITEL_MAX = 160;
|
||||
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();
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
@@ -865,6 +949,13 @@ bereicheRouter.use("/workspace/api/bereich/:bereich", (req, res, next) => {
|
||||
Faellt eines weg, haelt das andere. */
|
||||
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();
|
||||
/* Bretter mit eigener Kachel: wer die Kachel hat, darf hinein. */
|
||||
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
|
||||
dort nichts zu suchen. Der Kommentar eine Bildschirmseite weiter oben
|
||||
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") {
|
||||
/* 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:
|
||||
genau die Bretter des Treffs, sonst nichts. */
|
||||
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
|
||||
durch die Suche gekommen, obwohl das Brett zu war. */
|
||||
const frei = freigabeBedingung(person, praefix);
|
||||
if (!frei) return grund;
|
||||
return { wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] };
|
||||
if (!frei) return mitHaus(grund);
|
||||
return mitHaus({ wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] });
|
||||
}
|
||||
const regel = sichtbarRoh2(person, praefix);
|
||||
/* RIEGEL 2 -- wer nicht zum Treff gehoert, sieht seine Bretter nicht.
|
||||
Auch DogFather geht hier durch; er steht in TREFF_ROLLEN und faellt
|
||||
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(", ");
|
||||
return {
|
||||
return mitHaus({
|
||||
wo: `(${regel.wo}) AND ${praefix}.bereich NOT IN (${platz})`,
|
||||
werte: [...regel.werte, ...TREFF_BRETTER],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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) || [];
|
||||
}
|
||||
|
||||
/* ==== 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).
|
||||
@@ -2139,7 +2409,8 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/start", gleicheHerkunft, (r
|
||||
/* Der TAG in Ortszeit, der ZEITSTEMPEL in UTC -- das ist kein
|
||||
Widerspruch: Ein Zeitpunkt ist ueberall derselbe, ein
|
||||
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
|
||||
auf seinem Bildschirm gesehen hat.
|
||||
@@ -2260,7 +2531,7 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/:id/daraus",
|
||||
„Vorbei" und war zugeklappt -- waehrend beim Wunsch
|
||||
„Daraus wurde ein Termin" zu lesen war. */
|
||||
heuteLokal(), jetzt(), req.person.id, quelle.id,
|
||||
req.person.haus || null);
|
||||
hausFuerNeuenEintrag(req.person, nach));
|
||||
|
||||
protokolliere("bereich_daraus", {
|
||||
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. */
|
||||
aus.nur_leitung ?? 0,
|
||||
/* 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
|
||||
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 ------------------------------------------- */
|
||||
|
||||
/** 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) {
|
||||
const regel = sichtbarEintrag(req.person);
|
||||
if (!regel) return null;
|
||||
@@ -2674,6 +2980,114 @@ function darfBildAnhaengen(req, res, 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",
|
||||
gleicheHerkunft,
|
||||
darfBildAnhaengen,
|
||||
|
||||
@@ -46,6 +46,7 @@ import express from "express";
|
||||
import {
|
||||
db, sitzungLesen, protokolliere, echteIp, istDogFather, heuteLokal,
|
||||
ROLLEN_NAME,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
import {
|
||||
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 { lastInsertRowid } = db().prepare(`
|
||||
INSERT INTO eintraege
|
||||
(bereich, art, titel, text, datum, status, erstellt, erstellt_von, einsatz)
|
||||
VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?)`)
|
||||
(bereich, art, titel, text, datum, status, erstellt, erstellt_von, einsatz, haus)
|
||||
VALUES ('talente', 'empfohlen', ?, ?, ?, 'offen', ?, ?, ?, ?)`)
|
||||
.run(b.nenn_name,
|
||||
`Hat sich selbst gemeldet (Anfrage #${id}).`
|
||||
+ (antworten.get("warum") ? `\n\nWarum: ${antworten.get("warum")}` : ""),
|
||||
/* 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);
|
||||
|
||||
/* AUF „ANGESPROCHEN" und nicht auf „aufgefallen": Die ersten
|
||||
|
||||
+334
-11
@@ -43,6 +43,150 @@ import { join, extname, basename } from "node:path";
|
||||
import { mkdirSync, writeFileSync, statSync, unlinkSync,
|
||||
copyFileSync } from "node:fs";
|
||||
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 {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
|
||||
@@ -79,6 +223,7 @@ const NAME_MAX = 80;
|
||||
ein PDF mit Bildern selten über 10. */
|
||||
const ANHANG_MAX = 12 * 1024 * 1024;
|
||||
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
|
||||
|
||||
const ANHANG_NAME_MAX = 200;
|
||||
|
||||
/* 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.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
|
||||
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.anhang_art AS zitat_anhang
|
||||
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),
|
||||
text: n.weg_am ? null : n.text,
|
||||
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,
|
||||
selbst: n.person_id === req.person.id,
|
||||
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,
|
||||
dauer: n.anhang_dauer || 0,
|
||||
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,
|
||||
}));
|
||||
})(),
|
||||
@@ -2244,6 +2416,29 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
|
||||
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
|
||||
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 n = jetzt();
|
||||
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,
|
||||
dauer: dauer || 0,
|
||||
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,
|
||||
};
|
||||
@@ -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,
|
||||
dauer: 0,
|
||||
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);
|
||||
@@ -2545,7 +2757,29 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
|
||||
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." }); }
|
||||
|
||||
/* 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,
|
||||
dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
|
||||
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("Content-Security-Policy", "default-src 'none'; sandbox");
|
||||
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. */
|
||||
let dateienWeg = 0;
|
||||
for (const name of dateien) {
|
||||
try { unlinkSync(join(ANHANG_ORDNER, name)); dateienWeg++; }
|
||||
catch (f) {
|
||||
if (f?.code === "ENOENT") dateienWeg++;
|
||||
else console.error("[chat] Anhang bleibt liegen:", f?.message);
|
||||
}
|
||||
if (anhangWeg(name)) dateienWeg++;
|
||||
}
|
||||
|
||||
protokolliere("chat_aufgeloest", {
|
||||
@@ -3374,6 +3604,102 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
|
||||
const darfJedeNachrichtLoeschen = (person) =>
|
||||
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) => {
|
||||
try {
|
||||
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
|
||||
im schlechten Fall eine Zeile ohne Datei stehen. */
|
||||
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id);
|
||||
if (dateiWeg) {
|
||||
try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
|
||||
catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
|
||||
}
|
||||
if (dateiWeg) anhangWeg(dateiWeg);
|
||||
/* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende
|
||||
er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem
|
||||
es nichts mehr gibt. */
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds,
|
||||
istLeitung, istDogFather, siehtAlles, tagLokal,
|
||||
heuteLokal,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const contentRouter = express.Router();
|
||||
@@ -413,10 +414,10 @@ contentRouter.post("/workspace/api/content/:id/uebernehmen", gleicheHerkunft, (r
|
||||
|
||||
} else {
|
||||
const r = db().prepare(`
|
||||
INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von)
|
||||
VALUES ('live','vorbereitung',?,?,?,'offen',?,?,?)`).run(
|
||||
INSERT INTO eintraege (bereich, art, titel, text, datum, status, creator_id, erstellt, erstellt_von, haus)
|
||||
VALUES ('live','vorbereitung',?,?,?,'offen',?,?,?,?)`).run(
|
||||
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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
ohneTeamDogi, ohneAgentur, siehtModis,
|
||||
TREFF_BRETTER, TREFF_ROLLEN,
|
||||
} 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();
|
||||
|
||||
@@ -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);
|
||||
erlaubt = !!e && TREFF_BRETTER.includes(e.bereich)
|
||||
&& 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) {
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
|
||||
@@ -377,43 +377,37 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => {
|
||||
"neue Anfrage für Modi", "bewerbungen.html",
|
||||
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
|
||||
einziges Geraet angemeldet. Bei ihnen kommt keine einzige
|
||||
Erinnerung an, solange die Seite zu ist -- keine Aufgabe, kein
|
||||
Termin, kein Anruf.
|
||||
Hier stand bis zum 03.10.2026 der Hinweis "Bei dir klingelt
|
||||
nichts -- kein Geraet angemeldet", eingefuehrt am 19.09., als
|
||||
sieben von elf Zugaengen kein Geraet hatten.
|
||||
|
||||
Die Glocke oben rechts sagt das seit jeher korrekt ("Benach-
|
||||
richtigungen aus") und kennt sogar den iPhone-Sonderfall. Nur
|
||||
tippt sie niemand an: Sie ist ein stiller Schalter in einer
|
||||
Leiste, kein Hinweis. Deshalb steht es jetzt dort, wo man
|
||||
hinsieht.
|
||||
ER HAT DAS PROBLEM NICHT GELOEST. Am 03.10. nachgemessen:
|
||||
immer noch 12 von 20 ohne Anmeldung, darunter die linke Hand
|
||||
und ein Modi. Der Grund liegt auf der Hand -- er SAGT es, aber
|
||||
man kann nichts tun: Er fuehrt auf anruf-probe.html, wo man
|
||||
sich erst zurechtfinden muss.
|
||||
|
||||
DIESER HINWEIS GEHT NUR AN DIE PERSON SELBST, nicht an die
|
||||
Sicht. `req.person` und nicht `person`: Wenn DogFather die Lage
|
||||
eines Scouts ansieht, waere "bei DIR klingelt nichts" eine
|
||||
Aussage ueber den falschen Menschen -- und eine, die er nicht
|
||||
loesen kann.
|
||||
Ersetzt durch einen Kasten auf der Startseite mit einem
|
||||
"Anschalten"-Knopf (glocke.js, `hinweisZeichnen`). Ein
|
||||
Fingertipp statt eines Umwegs -- und er erklaert den
|
||||
iPhone-Fall, in dem Web-Push ohne Home-Bildschirm gar nicht
|
||||
geht. Das wusste hier niemand.
|
||||
|
||||
STUFE "offen", NICHT "warnung". Es ist kein Ausfall, sondern
|
||||
etwas, das man einmal einrichtet. Ein rotes Warnzeichen, das
|
||||
bei sieben von elf Leuten dauerhaft oben steht, waere nach
|
||||
einer Woche unsichtbar -- und mit ihm die echten Warnungen
|
||||
daneben.
|
||||
WARUM NICHT BEIDES: Der neue Kasten sitzt auf derselben Seite,
|
||||
direkt ueber dieser Liste. Zwei Saetze zur selben Sache auf
|
||||
EINEM Bildschirm sind kein doppelter Schutz, sondern zwei
|
||||
Stellen, die irgendwann Verschiedenes behaupten -- derselbe
|
||||
Grund, aus dem die Betreuungs-Auswahl am 31.08. zusammengelegt
|
||||
wurde.
|
||||
|
||||
ER VERSCHWINDET VON SELBST, sobald ein Geraet angemeldet ist.
|
||||
Nichts zu quittieren, nichts, was veralten kann -- Regel 1
|
||||
dieser Datei. */
|
||||
if (req.person?.id) {
|
||||
const geraete = zaehle(
|
||||
"SELECT COUNT(*) n FROM push_anmeldungen WHERE person_id = ?", [req.person.id]);
|
||||
if (!geraete) {
|
||||
dazu("kein_geraet", "offen",
|
||||
"Bei dir klingelt nichts – kein Gerät angemeldet",
|
||||
"anruf-probe.html", 1);
|
||||
}
|
||||
}
|
||||
WAS DAMIT AUFGEGEBEN IST, offen gesagt: Diese Zeile liess sich
|
||||
nicht wegklicken, der neue Kasten schon (einmalig, je Geraet).
|
||||
Eine Entscheidung von Filipe am 03.10. Die Gegensicherung ist
|
||||
die Personenliste: Dort steht seit heute bei jeder Person, die
|
||||
nichts bekommt, genau das -- die Leitung sieht es also
|
||||
weiterhin, auch wenn jemand den Kasten wegtippt. */
|
||||
|
||||
/* ---------- Manager-Ziele (02.10.2026) ----------
|
||||
|
||||
|
||||
@@ -756,7 +756,7 @@ function eintragAnlegen(person, rumpf, monat, heute) {
|
||||
return {
|
||||
fehler: "schon_eingetragen",
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -355,6 +355,26 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
|
||||
Scouts an ihm haengen. Beides aus derselben Abfrage --
|
||||
eine zweite waere eine zweite Gelegenheit, dass die
|
||||
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,
|
||||
(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
|
||||
|
||||
@@ -223,7 +223,21 @@ export function vapidKopf(endpunkt, paar, absender, stunden = 12) {
|
||||
* soll NICHT nachtraeglich aufploppen. Deshalb bekommt er eine kurze
|
||||
* TTL -- ist er vorbei, verfaellt auch die Meldung. */
|
||||
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 {
|
||||
const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth);
|
||||
const kopf = vapidKopf(anmeldung.endpunkt, paar, absender);
|
||||
@@ -232,8 +246,10 @@ export async function schicken(anmeldung, text, paar, absender,
|
||||
method: "POST",
|
||||
headers: {
|
||||
...kopf,
|
||||
TTL: String(dringend ? Math.min(ttl, 150) : ttl),
|
||||
Urgency: dringend ? "high" : "normal",
|
||||
/* Haltbarkeit und Dringlichkeit sind zwei Fragen -- siehe
|
||||
`eiligRegel` in workspace-push.js. */
|
||||
TTL: String(kurzlebig ? Math.min(ttl, 150) : ttl),
|
||||
Urgency: eilig ? "high" : "normal",
|
||||
},
|
||||
body: koerper,
|
||||
/* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */
|
||||
|
||||
+127
-21
@@ -113,19 +113,19 @@ const jetzt = () => new Date().toISOString();
|
||||
export const ARTEN = [
|
||||
{ schluessel: "aufgabe_faellig", name: "Aufgabe wird morgen fä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
|
||||
"termin_gleich": Die eine ist eine Vorgabe des Hauses fuer jeden
|
||||
Termin, der andere ist eine Entscheidung DIESER Person fuer DIESEN
|
||||
Termin. Wer die feste Stunden-Erinnerung abschaltet, will damit
|
||||
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: "followup", name: "Follow-up in der Pipeline fällig", vorgabe: true },
|
||||
/* Chat (06.09.2026). Steht bewusst VOR der Probe: Es ist die Art,
|
||||
die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht
|
||||
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) ------
|
||||
|
||||
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
|
||||
Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird,
|
||||
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) -----------------------------------
|
||||
|
||||
@@ -189,7 +189,7 @@ export const ARTEN = [
|
||||
|
||||
NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
|
||||
nachts; die Ruhezeit gilt. */
|
||||
{ schluessel: "support",
|
||||
{ schluessel: "support", eilig: true,
|
||||
name: "Support: neue Meldung oder Antwort darauf", vorgabe: true },
|
||||
|
||||
/* ---- MANAGER-ZIELE (02.10.2026) ---------------------------------
|
||||
@@ -224,7 +224,7 @@ export const ARTEN = [
|
||||
Eine eigene Art und nicht "termin_gleich": Das eine ist ein
|
||||
Kalendereintrag, das andere ein Stream, der JETZT laeuft. Wer das
|
||||
eine stumm stellt, meint selten das andere. */
|
||||
{ schluessel: "dogfather_live", name: "DogFather ist live", vorgabe: true,
|
||||
{ schluessel: "dogfather_live", eilig: true, name: "DogFather ist live", vorgabe: true,
|
||||
/* „spaet", nicht „immer": Ein Stream, der um 22:05 anfaengt, ist um
|
||||
22:05 interessant. Mit der alten Regel waere er stumm geblieben,
|
||||
und die Meldung, die Filipe ausdruecklich bestellt hat („die
|
||||
@@ -257,7 +257,7 @@ export const ARTEN = [
|
||||
|
||||
`ruhe: "spaet"` aus demselben Grund wie oben: Eine Reaction um
|
||||
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" },
|
||||
/* ---- 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
|
||||
jetzt diese Art ab -- eine Entscheidung, die man selbst trifft,
|
||||
statt einer Regel, die man nicht kennt. */
|
||||
{ schluessel: "anruf", name: "Jemand ruft an", vorgabe: true, ruhe: "nie" },
|
||||
{ schluessel: "anruf", eilig: true, name: "Jemand ruft an", vorgabe: true, ruhe: "nie" },
|
||||
/* DER TAGESRUF (08.09.2026, screen10). Filipe: "eine taetige meldung
|
||||
einmal am tag zu aktivieren wenn noch aufgaben auf sind."
|
||||
|
||||
@@ -323,10 +323,10 @@ export const ARTEN = [
|
||||
|
||||
IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint
|
||||
auf einem Sperrbildschirm, den auch andere sehen. */
|
||||
{ schluessel: "hilfe", name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
|
||||
{ schluessel: "hilfe", eilig: true, name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
|
||||
/* Die Probe beantwortet „kommt ueberhaupt etwas an?" -- und die
|
||||
stellt man sich auch um 23 Uhr. */
|
||||
{ schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" },
|
||||
{ schluessel: "test", eilig: true, name: "Probe-Benachrichtigung", vorgabe: true, ruhe: "nie" },
|
||||
];
|
||||
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";
|
||||
}
|
||||
|
||||
/** 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?
|
||||
*
|
||||
* `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 (!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
|
||||
die Probe -- nicht fuer eine Einladung: Die soll ankommen, wenn
|
||||
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? */
|
||||
if (merkmal) {
|
||||
@@ -633,14 +731,7 @@ export async function benachrichtige(personId, art, { titel, text, ziel, merkmal
|
||||
|
||||
let raus = 0;
|
||||
for (const a of anmeldungen) {
|
||||
/* EIN ANRUF IST DRINGEND, ALLES ANDERE NICHT (19.09.2026).
|
||||
|
||||
`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 });
|
||||
const e = await schicken(a, inhalt, paar, ABSENDER, { eilig, kurzlebig });
|
||||
if (e.ok) {
|
||||
raus++;
|
||||
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
|
||||
Ziel. */
|
||||
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. */
|
||||
const grenze = new Date(Date.now() - 60 * 86400_000).toISOString();
|
||||
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) {
|
||||
console.error("[push] Lauf:", fehler?.message);
|
||||
}
|
||||
|
||||
+315
-113
@@ -64,6 +64,19 @@ const ANTWORT_MAX = 2000;
|
||||
* Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */
|
||||
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.
|
||||
*
|
||||
* Zehn, und damit deutlich großzügiger als die drei im vertraulichen
|
||||
@@ -100,6 +113,160 @@ function textSauber(s, 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?
|
||||
*
|
||||
* Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe
|
||||
@@ -153,13 +320,15 @@ function nachDraussen(m, fuerLeitung, werFragtId) {
|
||||
geaendert: m.geaendert,
|
||||
erledigt_am: m.erledigt_am || null,
|
||||
seite: m.seite || null,
|
||||
/* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt
|
||||
jede Stelle im Browser denselben Pfad zusammen, und eine davon
|
||||
setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
|
||||
Chat-Anhang. */
|
||||
bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null,
|
||||
bild_breite: m.bild_breite || 0,
|
||||
bild_hoehe: m.bild_hoehe || 0,
|
||||
/* ==== EINE LISTE, AUCH WENN NUR EINES DRAN HÄNGT (02.10.2026) ==
|
||||
|
||||
Hier standen `bild`, `bild_breite`, `bild_hoehe` — drei Felder
|
||||
für genau ein Bild. Daneben eine Liste zu stellen hieße: zwei
|
||||
Auskünfte auf dieselbe Frage, und die zweite wird irgendwann
|
||||
nicht mehr mitgepflegt. Deshalb nur noch `bilder` — leer,
|
||||
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_rolle: m.von_rolle || null,
|
||||
/* 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. */
|
||||
function verlaufVon(id) {
|
||||
return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am,
|
||||
urteil_bild_datei
|
||||
return db().prepare(`SELECT nr, antwort, von_name, am, urteil, urteil_text, urteil_am
|
||||
FROM support_runden WHERE meldung_id = ? ORDER BY nr`).all(id)
|
||||
.map((r) => ({
|
||||
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_am: r.urteil_am || null,
|
||||
/* DER WEG, NICHT DER DATEINAME. Wie beim Bild der Meldung eine
|
||||
Zeile weiter oben: Was auf der Platte liegt, geht niemanden
|
||||
etwas an, der nur ein Bild anzeigen will. */
|
||||
urteil_bild: r.urteil_bild_datei
|
||||
? `/workspace/api/support/${id}/runde/${r.nr}/bild` : null,
|
||||
/* AUCH HIER EINE LISTE und dieselbe Form wie bei der Meldung —
|
||||
die Oberfläche hat dadurch EINE Funktion für beide Stellen
|
||||
statt zweier, die auseinanderlaufen. */
|
||||
bilder: bilderVon(id, r.nr),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -241,6 +408,11 @@ supportRouter.get("/workspace/api/support", (req, res) => {
|
||||
: db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
|
||||
WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n,
|
||||
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,
|
||||
});
|
||||
} 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
|
||||
* ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie,
|
||||
* die dem Dokument jede Ausführung nimmt. */
|
||||
supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => {
|
||||
* die dem Dokument jede Ausführung nimmt.
|
||||
*
|
||||
* 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 {
|
||||
const m = meldungFuer(req.person, req.params.id);
|
||||
if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
const pfad = join(BILD_ORDNER, m.bild_datei);
|
||||
if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
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 {
|
||||
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("Content-Security-Policy", "default-src 'none'; sandbox");
|
||||
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. */
|
||||
supportRouter.post("/workspace/api/support",
|
||||
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) => {
|
||||
let geschrieben = null;
|
||||
let geschrieben = [];
|
||||
try {
|
||||
let 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;
|
||||
if (Buffer.isBuffer(req.body) && req.body.length) {
|
||||
const erkannt = dateiErkennen(req.body);
|
||||
/* 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 zerlegt = rumpfZerlegen(req);
|
||||
if (zerlegt.fehler) {
|
||||
return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
|
||||
}
|
||||
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();
|
||||
/* 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
|
||||
(person_id, von_name, von_rolle, text, seite, geraet,
|
||||
bild_datei, bild_typ, bild_breite, bild_hoehe,
|
||||
stand, erstellt, geaendert)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`)
|
||||
.run(req.person.id, req.person.name || null, req.person.rolle || null,
|
||||
text, sauber(req.get("x-seite"), SEITE_MAX) || null,
|
||||
sauber(req.get("user-agent"), GERAET_MAX) || null,
|
||||
bildDatei, bildTyp, bildBreite, bildHoehe,
|
||||
SUPPORT_STAND.neu, n, n);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
bilderEintragen(id, null, abgelegt.bilder, n);
|
||||
|
||||
protokolliere("support_gemeldet", {
|
||||
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),
|
||||
});
|
||||
} catch (fehler) {
|
||||
/* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst
|
||||
sammelt der Ordner Bilder an, zu denen es keine Meldung gibt —
|
||||
und niemand merkt es, weil nichts darauf zeigt. */
|
||||
if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
|
||||
/* DIE DATEIEN GEHEN MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM.
|
||||
Sonst sammelt der Ordner Bilder an, zu denen es keine Meldung
|
||||
gibt — und niemand merkt es, weil nichts darauf zeigt.
|
||||
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);
|
||||
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
|
||||
trotzdem antworten können; ein Bild ist Hilfe, keine Hürde. */
|
||||
supportRouter.post("/workspace/api/support/:id/rueckmeldung",
|
||||
express.raw({ type: "*/*", limit: BILD_MAX }), (req, res) => {
|
||||
let geschrieben = null;
|
||||
express.raw({ type: "*/*", limit: BILD_MAX * BILDER_MAX }), (req, res) => {
|
||||
let geschrieben = [];
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
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
|
||||
Melden. `dateiErkennen` kennt auch PDF und Ton; hier geht es
|
||||
um Bildschirmfotos, und alles andere wäre eine Tür, nach der
|
||||
niemand gefragt hat. */
|
||||
let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0;
|
||||
if (Buffer.isBuffer(req.body) && req.body.length) {
|
||||
const erkannt = dateiErkennen(req.body);
|
||||
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;
|
||||
/* DIE BILDER -- derselbe Weg und dieselbe Absage wie beim
|
||||
Melden, und zwar wörtlich dieselben zwei Funktionen. Eine
|
||||
eigene Fassung hier wäre die, die beim nächsten Dateityp
|
||||
vergessen wird. */
|
||||
const zerlegt = rumpfZerlegen(req);
|
||||
if (zerlegt.fehler) {
|
||||
return res.status(zerlegt.fehler.code).json({ fehler: zerlegt.fehler.text });
|
||||
}
|
||||
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 runde = m.runde || 1;
|
||||
db().prepare(`UPDATE support_runden
|
||||
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)
|
||||
SET urteil = ?, urteil_text = ?, urteil_am = ?
|
||||
WHERE meldung_id = ? AND nr = ?`)
|
||||
.run(geht ? "geht" : "nicht", wort || null, n,
|
||||
bildDatei, bildTyp, bildDatei ? bildBreite : null,
|
||||
bildDatei ? bildHoehe : null, m.id, runde);
|
||||
.run(geht ? "geht" : "nicht", wort || null, n, 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) {
|
||||
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);
|
||||
res.json({ meldung: nachDraussen(frisch, false, req.person.id) });
|
||||
} 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
|
||||
Bildordner Dateien an, zu denen es keine Zeile gibt, und
|
||||
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);
|
||||
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
|
||||
* Meldung und das einer Antwort sind zwei verschiedene Dinge mit
|
||||
* verschiedenen Nummern. Eine Route, die mal das eine und mal das
|
||||
* andere liefert, braucht beim Lesen jedes Mal einen Blick in den
|
||||
* Quelltext.
|
||||
*
|
||||
* 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" });
|
||||
}
|
||||
});
|
||||
* Sie lieferte das EINE Bild einer Antwort und hätte für drei eine
|
||||
* laufende Nummer dazubekommen müssen. Statt zwei fast gleicher
|
||||
* Routen mit je einer Zählung gibt es jetzt eine einzige weiter
|
||||
* oben: `/:id/bild/:bid`. Wohin ein Bild gehört, steht in seiner
|
||||
* Zeile — der Weg muss es nicht wiederholen. */
|
||||
|
||||
supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => {
|
||||
try {
|
||||
@@ -743,13 +931,27 @@ supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, re
|
||||
export function supportAufraeumen() {
|
||||
try {
|
||||
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);
|
||||
if (!alt.length) return 0;
|
||||
const dazu = db().prepare("SELECT datei FROM support_bilder WHERE meldung_id = ?");
|
||||
for (const m of alt) {
|
||||
if (m.bild_datei) {
|
||||
try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ }
|
||||
/* ALLE BILDER DER MELDUNG, auch die aus den Antwortrunden
|
||||
(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);
|
||||
}
|
||||
console.log(`[support] ${alt.length} erledigte Meldungen aelter als `
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
db, sitzungLesen, protokolliere, echteIp,
|
||||
TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME, istHand,
|
||||
heuteLokal,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
/* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene
|
||||
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 r = db().prepare(`INSERT INTO eintraege
|
||||
(bereich, art, titel, text, datum, dringlichkeit, status, creator_id,
|
||||
erstellt, erstellt_von, einsatz)
|
||||
VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?)`)
|
||||
erstellt, erstellt_von, einsatz, haus)
|
||||
VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?,?)`)
|
||||
.run(
|
||||
`${wer?.name ?? "Jemand"} hat sich im Rudel gemeldet`,
|
||||
`Aus „Mitmachen“: ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
|
||||
/* 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),
|
||||
detail: `aus Eintrag ${id}` });
|
||||
res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
|
||||
|
||||
+141
-15
@@ -46,8 +46,9 @@ import { join } from "node:path";
|
||||
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER,
|
||||
KANAELE, kanalVonHandle, TREFF_BRETTER,
|
||||
KANAELE, kanalVonHandle, TREFF_BRETTER, brettAufDieserAdresse,
|
||||
heuteLokal,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
import { darfSchreiben, sofortFreigeben } from "./workspace-treff.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. */
|
||||
/** 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 = {}) {
|
||||
const abbruch = new AbortController();
|
||||
const uhr = setTimeout(() => abbruch.abort(), FRIST_MS);
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -114,13 +140,81 @@ async function holeMitFrist(adresse, kopf = {}) {
|
||||
const OEMBED_BASIS = process.env.TIKTOK_OEMBED_BASIS || "https://www.tiktok.com/oembed";
|
||||
|
||||
/** 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(
|
||||
`${OEMBED_BASIS}?url=${encodeURIComponent(url)}`,
|
||||
{ accept: "application/json" });
|
||||
if (!a.ok) return { fehler: `TikTok antwortet nicht (${a.status}).` };
|
||||
const j = await a.json().catch(() => null);
|
||||
if (!j || !j.title) return { fehler: "TikTok hat nichts zu diesem Video geschickt." };
|
||||
if (!a.ok) return { status: a.status, daten: null };
|
||||
let j = null;
|
||||
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
|
||||
ANZEIGENAME und aendert sich, wenn jemand ihn umstellt -- als
|
||||
Schluessel waere er die falsche Wahl. */
|
||||
@@ -140,9 +234,7 @@ async function coverAblegen(adresse) {
|
||||
let a;
|
||||
try { a = await holeMitFrist(adresse); } catch { return null; }
|
||||
if (!a.ok) return null;
|
||||
const lang = Number(a.headers.get("content-length") || 0);
|
||||
if (lang > COVER_MAX) return null;
|
||||
const roh = Buffer.from(await a.arrayBuffer());
|
||||
const roh = a.roh;
|
||||
if (!roh.length || roh.length > COVER_MAX) return null;
|
||||
/* DIESELBE BYTEPRUEFUNG WIE BEI HOCHGELADENEN DATEIEN. Was von
|
||||
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;
|
||||
try {
|
||||
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" });
|
||||
}
|
||||
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();
|
||||
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." });
|
||||
}
|
||||
|
||||
@@ -226,7 +329,20 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
|
||||
}
|
||||
|
||||
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
|
||||
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(`
|
||||
INSERT INTO eintraege
|
||||
(bereich, art, titel, datum, status, erstellt, erstellt_von,
|
||||
quelle_url, quelle_kanal, quelle_geholt_am, aus_eintrag_id)
|
||||
VALUES (?,?,?,?, 'offen', ?,?,?,?,?,?)`)
|
||||
quelle_url, quelle_kanal, quelle_geholt_am, aus_eintrag_id, haus)
|
||||
VALUES (?,?,?,?, 'offen', ?,?,?,?,?,?,?)`)
|
||||
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel daneben in
|
||||
UTC -- ein Zeitpunkt ist ueberall derselbe, ein Kalendertag
|
||||
nicht. */
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -335,6 +452,14 @@ videoRouter.post("/workspace/api/bereich/:bereich/video", async (req, res) => {
|
||||
Datei ohne Eintrag findet niemand wieder und niemand loescht sie
|
||||
-- sie liegt einfach fuer immer da. */
|
||||
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);
|
||||
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})` };
|
||||
}
|
||||
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
|
||||
mit Status 200. Ein leerer Titel ist deshalb eine Aussage. */
|
||||
if (!j || !j.title) return { da: false, grund: "Antwort ohne Titel" };
|
||||
|
||||
+201
-21
@@ -56,6 +56,7 @@ import {
|
||||
KATALOG_EMPFAENGER,
|
||||
KATALOG_EMPFAENGER_SQL,
|
||||
heuteLokal,
|
||||
hausFuerNeuenEintrag,
|
||||
} from "./workspace.js";
|
||||
|
||||
/* 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,
|
||||
* 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 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")}`;
|
||||
}
|
||||
|
||||
/** 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
|
||||
(titel, beschreibung, status, prioritaet, verantwortlich_id, frist,
|
||||
kategorie, vorlage, erstellt, erstellt_von)
|
||||
VALUES (?,?,'offen',?,?,?,?,?,?,?)`)
|
||||
kategorie, vorlage, dauerhaft, erstellt, erstellt_von)
|
||||
VALUES (?,?,'offen',?,?,?,?,?,?,?,?)`)
|
||||
.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;
|
||||
anmerkungAnlegen(id, wunsch, vonId);
|
||||
const nun = jetzt();
|
||||
db().prepare(`INSERT INTO aufgaben_zuteilung
|
||||
(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. */
|
||||
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)
|
||||
|
||||
@@ -1624,7 +1679,7 @@ function vorlagenAntwortenFuer(person) {
|
||||
try {
|
||||
return db().prepare(`
|
||||
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
|
||||
LEFT JOIN personen e ON e.id = b.entschieden_von
|
||||
WHERE b.person_id = ?
|
||||
@@ -1632,7 +1687,16 @@ function vorlagenAntwortenFuer(person) {
|
||||
AND COALESCE(b.entschieden_am, '') >= ?
|
||||
ORDER BY b.entschieden_am DESC`)
|
||||
.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) {
|
||||
console.error("[vorlagen] Antworten:", fehler?.message);
|
||||
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 ------------------------------
|
||||
|
||||
EINE SCHLEIFE FUER BEIDE WEGE. Zwei fast gleiche Routen waeren zwei
|
||||
@@ -2017,6 +2135,66 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
|
||||
try {
|
||||
const art = String(req.body.art || "");
|
||||
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,
|
||||
den sie gerade ansehen. */
|
||||
let creatorId = req.person.rolle === "creator"
|
||||
@@ -2035,14 +2213,14 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
|
||||
const einfuegen = (bereich, unterart, titel, text, zusatz = {}) => {
|
||||
db().prepare(`INSERT INTO eintraege
|
||||
(bereich, art, titel, text, datum, status, creator_id, hook, format,
|
||||
erstellt, erstellt_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
erstellt, erstellt_von, haus)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
/* Der TAG in Ortszeit (01.10.2026), der Zeitstempel weiter
|
||||
unten in UTC. Aus dem Startkatalog angelegte Eintraege
|
||||
trugen sonst nachts das Datum von gestern. */
|
||||
.run(bereich, unterart, titel, text, heuteLokal(), "offen",
|
||||
creatorId, zusatz.hook || null, zusatz.format || null,
|
||||
jetzt(), req.person.id);
|
||||
jetzt(), req.person.id, hausFuerNeuenEintrag(req.person, bereich));
|
||||
angelegt.push(titel);
|
||||
};
|
||||
|
||||
@@ -2095,22 +2273,24 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
|
||||
const auswahl = art === "aufgaben_alle" ? feld : [feld[nr]];
|
||||
if (auswahl.some((v) => !v)) return res.status(400).json({ fehler: "Unbekannte Vorlage." });
|
||||
for (const v of auswahl) {
|
||||
/* 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 frist = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
|
||||
+ `-${String(d.getDate()).padStart(2, "0")}`;
|
||||
/* Frist, „dauerhaft" und die Anmerkung kommen aus demselben
|
||||
`wunsch` wie im Katalogzweig -- und aus derselben
|
||||
Funktion. Zwei Rechnungen fuer dieselbe Frist waeren die
|
||||
zweite, die beim naechsten Mal nicht mitgeaendert wird. */
|
||||
const frist = fristAusWunsch(v, wunsch);
|
||||
/* Die Kennung der Vorlage wandert mit (09.09.2026). Nur so
|
||||
kann das Brett spaeter zeigen, was schon uebernommen ist --
|
||||
und der Titel taugt dafuer nicht: Er darf geaendert werden. */
|
||||
db().prepare(`INSERT INTO aufgaben
|
||||
(titel, beschreibung, status, prioritaet, creator_id, frist,
|
||||
vorlage, erstellt, erstellt_von)
|
||||
VALUES (?,?,'offen',?,?,?,?,?,?)`)
|
||||
vorlage, dauerhaft, erstellt, erstellt_von)
|
||||
VALUES (?,?,'offen',?,?,?,?,?,?,?)`)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -2298,7 +2478,7 @@ vorlagenRouter.post("/workspace/api/vorlagen/uebernehmen", gleicheHerkunft,
|
||||
: new Set();
|
||||
for (const v of auswahl) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,61 +760,38 @@ for (const [weg, entscheidung] of [["annehmen", "ja"], ["ablehnen", "nein"]]) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Meinen eigenen Stand setzen ------------------------------------ */
|
||||
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" });
|
||||
/* ==== HIER STAND `/mein-stand` (03.10.2026 entfernt) ==============
|
||||
|
||||
const ziel = String(req.body?.zustand ?? "").trim();
|
||||
/* NUR DIE DREI, DIE MAN SELBST SETZEN DARF. "offen" zurückzudrehen
|
||||
hieße, die eigene Zusage zu löschen; "abgelehnt" hat einen
|
||||
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" });
|
||||
VanVan im Support, Meldung #8: „Wenn man auf ich fange an drückt
|
||||
steht dort in Bearbeitung und wenn man auf fertig drückt dann wird
|
||||
es zu erledigt. DIE AUFGABE BLEIBT ABER IM STATUS OFFEN STEHEN."
|
||||
|
||||
/* ==== 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
|
||||
somit nicht vom Modi in den Status erledigt gesetzt werden
|
||||
kann."
|
||||
DAS WAR EINE AUSREDE, KEINE BEGRÜNDUNG. Eine Route ist keine
|
||||
Schranke gegen sich selbst. Sie setzte weiterhin NUR
|
||||
`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
|
||||
man TUT. „Neue begruessen" ist nicht fertig, wenn man es einmal
|
||||
gemacht hat.
|
||||
NACHGEMESSEN, BEVOR SIE WEGKAM: Kein einziger Aufruf mehr im
|
||||
ausgelieferten Browsercode (`grep` über alle JS- und HTML-Dateien
|
||||
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
|
||||
einen Anfang, und der Unterschied zwischen „angenommen" und „in
|
||||
Arbeit" sagt etwas. Gesperrt ist nur das Abhaken.
|
||||
WAS STATTDESSEN GILT: `PATCH /workspace/api/aufgaben/:id` mit
|
||||
`{ status }`. Er setzt den Status UND zieht die Zuteilung mit
|
||||
(`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
|
||||
Knopf ist eine Bitte; abgelehnt wird am Server -- wer die
|
||||
Schnittstelle direkt anspricht, kaeme sonst durch.
|
||||
ENTFERNT STATT AUSKOMMENTIERT — dieselbe Entscheidung wie bei
|
||||
`/vorlagen/hilfe` am 01.09.: Eine Route, die niemand mehr aufruft,
|
||||
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) ----------------------------------------- */
|
||||
zuteilungRouter.post("/workspace/api/aufgaben/:id/bewerten", (req, res) => {
|
||||
|
||||
@@ -145,6 +145,25 @@ export const WIE_RECHTE_HAND = new Set(["hand", "linke"]);
|
||||
/** Hat diese Person die Team-Rechte einer Hand (rechts oder links)? */
|
||||
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) ========================
|
||||
|
||||
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
|
||||
Rolle bekommt gar nichts"; ihr eine einzelne Kachel zu geben waere
|
||||
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) {
|
||||
const liste = bereicheRoh(person);
|
||||
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
|
||||
* 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([
|
||||
...HAND_MIT_TREFF
|
||||
.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
|
||||
Byte-Grenze, nicht diese Zahl. */
|
||||
["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).
|
||||
|
||||
@@ -4384,6 +4499,40 @@ function umstellungen(d) {
|
||||
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)
|
||||
=====================================================================
|
||||
@@ -5182,6 +5331,46 @@ export function db() {
|
||||
);
|
||||
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").
|
||||
Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in
|
||||
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_am TEXT,
|
||||
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
|
||||
);
|
||||
/* EINMAL BEWERBEN, NICHT DREIMAL -- und zwar von der Datenbank
|
||||
@@ -5842,6 +6036,57 @@ export function db() {
|
||||
geaendert TEXT
|
||||
);
|
||||
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
|
||||
dieser Bauplan durch ist -- sie haengen an eintraege und
|
||||
personen und koennen deshalb nicht davor stehen. Ihr SQL steht
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
verdeckt. Gefunden hat es pruef-struktur.
|
||||
|
||||
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/haus.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
<!-- Ohne diese drei nimmt ein iPhone einen Bildschirmausschnitt als
|
||||
Symbol, Android zeichnet die Leiste weiss, und die Seite oeffnet
|
||||
ausserhalb der App -- auf einer Seite, die genau erklaeren soll,
|
||||
@@ -33,7 +33,7 @@
|
||||
schreibt jede Seite app.webmanifest -- auch die hier. -->
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<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=202610051932" />
|
||||
<style>
|
||||
/* Bewusst in der Seite und nicht in einer eigenen Datei: Diese Seite
|
||||
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/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/anruf-probe.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,25 +23,25 @@
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-192.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/workspace-192.png?v=202610051932",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-512.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/workspace-512.png?v=202610051932",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202610051932",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202610051932",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
@@ -2111,6 +2111,67 @@
|
||||
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 --------------------------------
|
||||
ZUGEKLAPPT, solange niemand hinsieht. Es ist eine Auskunft zum
|
||||
Nachschlagen, kein Aushang: Eine Liste von Absagen, die immer offen
|
||||
|
||||
@@ -543,85 +543,28 @@
|
||||
Zustand zusaetzlich als WORT da ("läuft bis 30.09.2026").
|
||||
===================================================================== */
|
||||
|
||||
.event-zeitraum {
|
||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
/* ---------------------------------------------------------------------
|
||||
DIE ALTEN BAUSTEINE SIND AM 03.10.2026 WEGGEFALLEN.
|
||||
|
||||
/* Ein vorbeigelaufenes Event bleibt lesbar, tritt aber zurueck. Kein
|
||||
display:none -- was gelaufen ist, will man nachschlagen koennen.
|
||||
Gedaempft wird nur die Flaeche, nicht die Schrift: Text unter 4.5:1
|
||||
waere nicht mehr lesbar, und "alt" ist kein Grund, jemanden
|
||||
auszuschliessen. */
|
||||
.eintrag-karte[data-event="vorbei"] { opacity: .82; }
|
||||
Hier standen `.event-zeitraum*` und `.event-block*` -- die
|
||||
Zeitraumzeile ueber der Karte und die zwei gleich aussehenden
|
||||
grauen Kaesten fuer Aufgaben und Regeln. Beide erzeugt bereich.js
|
||||
nicht mehr: Der Zeitraum steht jetzt auf der Buehne (einmal statt
|
||||
dreimal), und aus den Kaesten sind Listen geworden, die man abhaken
|
||||
kann. Die neuen Regeln stehen weiter unten unter "AGENTUR-EVENTS:
|
||||
DIE KARTE ALS AUSHANG".
|
||||
|
||||
.event-block {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius-klein);
|
||||
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;
|
||||
}
|
||||
SIE WURDEN ENTFERNT UND NICHT AUFGEHOBEN: Eine Regel, die auf nichts
|
||||
mehr zeigt, sieht beim naechsten Lesen aus wie eine, die etwas tut
|
||||
-- und man sucht den Fehler dann an einer Stelle, an der nichts
|
||||
passiert. Was sie aussagten, steht in der Begruendung der neuen
|
||||
Regeln; die Geschichte selbst steht in git.
|
||||
|
||||
/* .feld-hinweis steht seit dem 07.09.2026 in aufgaben.css -- bei den
|
||||
uebrigen Formularstilen. Grund: Die Dashboard-Seite braucht ihn jetzt
|
||||
auch (Creator anlegen), laedt aber diese Datei hier nicht. Ein
|
||||
Formularbaustein, der in der Bereichsdatei liegt, ist nur so lange
|
||||
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%; }
|
||||
}
|
||||
Ebenfalls weg: `.eintrag-karte[data-event="vorbei"] { opacity: .82 }`.
|
||||
Ein abgelaufenes Event war damit kaum von einem laufenden zu
|
||||
unterscheiden, und der Text wurde schlechter lesbar, ohne dass es
|
||||
etwas brachte. Jetzt tritt nur das BILD zurueck.
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
/* =====================================================================
|
||||
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;
|
||||
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; }
|
||||
|
||||
@@ -2227,6 +2227,7 @@
|
||||
.chat-nachricht__antwort-knopf,
|
||||
.chat-nachricht__reag-knopf,
|
||||
.chat-nachricht__kopie-knopf,
|
||||
.chat-nachricht__bearb-knopf,
|
||||
.chat-nachricht__weg-knopf {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
border: 1px solid transparent;
|
||||
@@ -2248,6 +2249,7 @@
|
||||
.chat-nachricht__reag-knopf::before,
|
||||
.chat-nachricht__kopie-knopf::before,
|
||||
.chat-nachricht__pin-knopf::before,
|
||||
.chat-nachricht__bearb-knopf::before,
|
||||
.chat-nachricht__weg-knopf::before {
|
||||
content: ""; flex: none;
|
||||
width: 13px; height: 13px;
|
||||
@@ -5734,3 +5736,80 @@
|
||||
@media (pointer: coarse) {
|
||||
.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; }
|
||||
|
||||
@@ -728,8 +728,58 @@
|
||||
lassen. Genau dafür ist er gemacht, und genau deshalb war er unter
|
||||
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 {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(580px, calc(100vw - 32px));
|
||||
padding: 0;
|
||||
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
|
||||
Bildschirmfoto vom Handy ist sonst höher als der ganze Dialog, und
|
||||
die Knöpfe darunter wären nicht mehr zu sehen. */
|
||||
.nachfrage__vorschau {
|
||||
die Knöpfe darunter wären nicht mehr zu sehen.
|
||||
|
||||
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;
|
||||
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-height: 160px;
|
||||
max-height: 120px;
|
||||
width: auto;
|
||||
border-radius: 10px;
|
||||
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. */
|
||||
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 textarea {
|
||||
width: 100%;
|
||||
@@ -1058,6 +1145,42 @@ body > .dialog__knoepfe { position: static; background: none; backdrop-filter: n
|
||||
welche Seitendatei zufällig geladen ist. */
|
||||
.nachfrage__form input { height: 44px; }
|
||||
.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 textarea:focus {
|
||||
outline: none;
|
||||
|
||||
@@ -652,7 +652,16 @@ body.reaktion-seite > .kopfleiste { flex: none; }
|
||||
/* Ein Schild, damit niemand ein stehendes Bild fuer eine
|
||||
eingefrorene Kamera haelt. */
|
||||
.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);
|
||||
}
|
||||
.kamera__name {
|
||||
@@ -2411,7 +2420,8 @@ body.reaktion-seite > .kopfleiste { flex: none; }
|
||||
unterscheidet, sieht ihn gar nicht. Ein Wort kann man nicht
|
||||
uebersehen. Fuer Hilfsmittel steht daneben `aria-pressed`. */
|
||||
.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;
|
||||
}
|
||||
.muenzsatz__wort {
|
||||
|
||||
@@ -4783,6 +4783,74 @@ body[data-fremde-sicht="ja"]::after {
|
||||
.glocke[data-lage="verboten"] .glocke__strich,
|
||||
.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
|
||||
aria-label, Vorleseprogramme verlieren also nichts.
|
||||
|
||||
|
||||
@@ -71,13 +71,66 @@
|
||||
}
|
||||
.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 {
|
||||
display: block; max-width: 100%; max-height: 260px;
|
||||
display: block; max-width: 100%; max-height: 200px;
|
||||
width: auto; height: auto;
|
||||
border-radius: var(--radius-klein);
|
||||
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 {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
@@ -175,7 +228,12 @@
|
||||
.s-karte {
|
||||
--s: var(--text-still);
|
||||
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;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 0;
|
||||
@@ -247,15 +305,53 @@
|
||||
.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; }
|
||||
|
||||
/* ==== 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 {
|
||||
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 …"
|
||||
untereinander schreibt, soll es so wiederfinden. */
|
||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.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 {
|
||||
display: block; max-width: 100%; max-height: 300px;
|
||||
width: auto; height: auto;
|
||||
@@ -292,24 +388,14 @@
|
||||
Dieselbe Fase, derselbe heller werdende Rand und dieselbe
|
||||
Fokusmarke wie oben: Was gleich funktioniert, soll gleich
|
||||
aussehen. */
|
||||
.s-runde__bild {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.s-runde__bild img {
|
||||
display: block; max-width: 100%; max-height: 180px;
|
||||
width: auto; height: auto;
|
||||
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; }
|
||||
/* SEIT DEM 02.10. DERSELBE KASTEN wie oben (`.s-karte__bild`), nur
|
||||
kleiner. Hier stand eine eigene Klasse `.s-runde__bild` mit fast
|
||||
denselben zwanzig Zeilen -- zwei Stellen, an denen dasselbe steht,
|
||||
und beim naechsten Mal wird nur eine davon verbessert. Was bleibt,
|
||||
ist der Groessenunterschied, und der ist der ganze Grund: */
|
||||
.s-runde .s-karte__bild { margin-top: 8px; }
|
||||
.s-runde .s-karte__bild img { max-height: 180px; }
|
||||
.s-runde .s-karte__bild[data-viele] img { max-height: 120px; }
|
||||
|
||||
/* =====================================================================
|
||||
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
|
||||
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) {
|
||||
.s-karte:has(.s-karte__bild) {
|
||||
.s-karte:has(> .s-karte__bild) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
@@ -341,12 +434,12 @@
|
||||
column-gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.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__wo { grid-area: wo; }
|
||||
.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__bild {
|
||||
.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__wo { grid-area: wo; }
|
||||
.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__bild {
|
||||
grid-area: bild; margin-top: 0; align-self: start;
|
||||
}
|
||||
/* 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
|
||||
also. Ein Handy-Bildschirmfoto ist bei 180 px noch als solches zu
|
||||
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; }
|
||||
|
||||
@@ -595,41 +694,200 @@
|
||||
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
|
||||
perfekt sein werden."
|
||||
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."
|
||||
|
||||
Die Runden stehen untereinander an einer Linie -- die Form, in der
|
||||
man seit jeher einen VERLAUF liest. Ein Kasten je Runde waere bei
|
||||
vier Runden ein Stapel Kaesten; die Linie haelt sie zusammen. */
|
||||
.inhalt.support-seite .s-verlauf {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--s-lila) 32%, transparent);
|
||||
display: grid; gap: 12px;
|
||||
WAS VORHER DASTAND, an einem Bild mit zwei Runden gemessen: vier
|
||||
Textzeilen untereinander, alle in derselben Farbe. Bei fuenf Runden
|
||||
muss man die Runden zaehlen, statt sie zu sehen.
|
||||
|
||||
DREI TEILE:
|
||||
· `.s-rundenleiste` alle Runden auf einen Blick, ein Punkt je
|
||||
Runde in ihrer Farbe, mit ihrem Ausgang
|
||||
· `.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 {
|
||||
font-size: .72rem; font-weight: 800; letter-spacing: .08em;
|
||||
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__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;
|
||||
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
|
||||
ANTWORT auf die Zeile darueber, nicht eine zweite Meldung. */
|
||||
.s-runde__urteil {
|
||||
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);
|
||||
/* DIE LEITUNG HAT DIE FARBE DER RUNDE -- sie hat sie eroeffnet. */
|
||||
.s-schritt[data-wer="leitung"] {
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--farbe) 55%, transparent);
|
||||
}
|
||||
.s-runde__urteil-wort { font-weight: 750; }
|
||||
.s-runde__urteil[data-urteil="geht"] .s-runde__urteil-wort { color: #8fe3b0; }
|
||||
.s-runde__urteil[data-urteil="nicht"] .s-runde__urteil-wort { color: #ffb3a7; }
|
||||
.s-schritt[data-wer="leitung"] .s-schritt__schild {
|
||||
color: color-mix(in srgb, var(--farbe) 38%, #ffffff);
|
||||
}
|
||||
/* 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 --
|
||||
er darf als Einziger auf dieser Seite gruen sein. */
|
||||
|
||||
@@ -419,7 +419,23 @@
|
||||
: a.darf_status === true;
|
||||
if (darfTun) {
|
||||
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:
|
||||
Auch etwas Abgehaktes kann sich als hinfällig herausstellen.
|
||||
@@ -748,6 +764,25 @@
|
||||
ziel.textContent = '';
|
||||
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
|
||||
Personenabfrage: Dann stehen dort genau die Creator, die man
|
||||
ohnehin sehen darf -- und niemals einer mehr. */
|
||||
@@ -764,24 +799,24 @@
|
||||
|
||||
/* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather
|
||||
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
|
||||
anderen creator geben wie mich."
|
||||
Am 24.09. war aufgefallen, dass die Reihe auf der Team-Adresse
|
||||
„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
|
||||
Reihe war mit „Alle Creator" beschriftet, und darunter standen
|
||||
Modis. Dort gibt es keine Creator -- die Rollen auf crew. sind
|
||||
rechte Hand, linke Hand und Modi.
|
||||
Am 03.10. ist die Reihe auf der Team-Adresse ganz entfallen
|
||||
(siehe oben). Die Beschriftung „Für wen" ist damit nicht mehr
|
||||
erreichbar und wurde entfernt statt stehengelassen: Ein Zweig,
|
||||
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
|
||||
hier waere die zweite Wahrheit und ausserdem falsch fuer
|
||||
DogFather, der in beiden Haeusern arbeitet. */
|
||||
const imTeam = ich.haus === 'crew';
|
||||
Was bleibt, ist die Unterscheidung innerhalb der Agentur:
|
||||
„Deine Creator" stimmt fuer Scout und Manager; bei DogFather
|
||||
waeren es ALLE, und „deine" waere dort eine falsche Auskunft. */
|
||||
ziel.append(el('span', 'creator-reihe__schild',
|
||||
imTeam
|
||||
? 'Für wen'
|
||||
: (ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator')));
|
||||
ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator'));
|
||||
const knopf = (id, name, offen, faellig) => {
|
||||
const b = el('button', 'creator-knopf');
|
||||
b.type = 'button';
|
||||
|
||||
+830
-30
@@ -374,6 +374,79 @@
|
||||
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.
|
||||
Er stand ACHTMAL da, Zeichen fuer Zeichen gleich -- und als
|
||||
die Regel darin sich als falsch herausstellte (jeder 404
|
||||
@@ -428,8 +501,14 @@
|
||||
feld.click();
|
||||
}
|
||||
|
||||
function tuKnopf(text, tu) {
|
||||
const b = el('button', 'schritt', text);
|
||||
/* `klasse` kam am 03.10.2026 dazu, fuer den Loeschknopf an der
|
||||
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.addEventListener('click', async () => {
|
||||
b.disabled = true; melde('');
|
||||
@@ -509,10 +588,319 @@
|
||||
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) {
|
||||
const k = el('article', 'eintrag-karte');
|
||||
k.dataset.dringlich = e.dringlichkeit;
|
||||
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).
|
||||
|
||||
Am Anschlagbrett traegt sie die Farbe: Ansage, Neue Regel,
|
||||
@@ -701,7 +1089,11 @@
|
||||
nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein
|
||||
echter Verweis mit Beschriftung -- das kann die Tastatur, ohne
|
||||
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');
|
||||
if (e.bilder.length > 1) bg.dataset.mehrere = 'ja';
|
||||
for (const b of e.bilder) {
|
||||
@@ -745,7 +1137,12 @@
|
||||
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 ----------
|
||||
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
|
||||
erst die Aufgaben und danach, dass es seit einer Woche vorbei
|
||||
ist. */
|
||||
const stand = istEventArt(e.art) ? eventStand(e) : null;
|
||||
if (stand) {
|
||||
k.dataset.event = stand.schluessel;
|
||||
const zeile = el('p', 'event-zeitraum');
|
||||
zeile.dataset.stand = stand.schluessel;
|
||||
zeile.append(el('span', 'event-zeitraum__marke', stand.text));
|
||||
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);
|
||||
}
|
||||
const stand = evStand;
|
||||
/* Der Zustand bleibt als Merkmal an der Karte -- daran haengt im
|
||||
CSS, wie ein vorbeigelaufenes Event zuruecktritt. Die ZEILE, die
|
||||
hier frueher stand ("laeuft bis 16.10.2026" plus Spanne), ist
|
||||
weg: Sie steht jetzt in der Buehne, und zwar einmal. */
|
||||
if (stand) k.dataset.event = stand.schluessel;
|
||||
|
||||
if (e.text) {
|
||||
const t = el('p', 'eintrag-text');
|
||||
@@ -894,15 +1285,35 @@
|
||||
}
|
||||
|
||||
if (stand) {
|
||||
for (const [feld, name] of [['event_aufgaben', 'Aufgaben'], ['event_regeln', 'Regeln']]) {
|
||||
if (!e[feld]) continue;
|
||||
const block = el('div', 'event-block');
|
||||
block.append(el('h4', 'event-block__schild', name));
|
||||
/* Zeilenumbrueche bleiben stehen (white-space: pre-line im CSS).
|
||||
Eine Aufzaehlung, die zu einem Absatz zusammenlaeuft, ist
|
||||
genau das, was diese beiden Felder verhindern sollen. */
|
||||
block.append(el('p', 'event-block__text', e[feld]));
|
||||
k.append(block);
|
||||
/* DIE ZERLEGUNG MACHT DER SERVER (event_punkte /
|
||||
event_regelpunkte). Sie hier zu wiederholen hiesse, die
|
||||
Rechenvorschrift fuer den Schluessel eines Punktes an zwei
|
||||
Stellen zu haben -- und ein Haken, der nach einem Umbau am
|
||||
falschen Punkt haengt, faellt niemandem auf.
|
||||
|
||||
FAELLT DIE AUSKUNFT AUS (alte Serverfassung, leeres Feld),
|
||||
bleibt der Textblock als Rueckfall stehen. Lieber eine
|
||||
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",
|
||||
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).
|
||||
|
||||
Fuer die Community ist er es nicht -- `kalender.html` steht in
|
||||
@@ -1459,7 +1875,7 @@
|
||||
return;
|
||||
}
|
||||
await laden();
|
||||
}));
|
||||
}, 'schritt--gefahr'));
|
||||
}
|
||||
fuss.append(knoepfe);
|
||||
k.append(fuss);
|
||||
@@ -2775,6 +3191,346 @@
|
||||
Oeffnen zu schalten waere der bequeme Fehler: Man oeffnet mit
|
||||
"Schulung", waehlt "Agentur-Events" und fuellt ein Formular aus,
|
||||
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() {
|
||||
const event = istEventArt($('f-art').value);
|
||||
|
||||
@@ -2791,6 +3547,18 @@
|
||||
$('feld-ende').hidden = !event;
|
||||
$('feld-aufgaben').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?.vertraulich) $('feld-vertraulich').hidden = event;
|
||||
|
||||
@@ -2845,13 +3613,19 @@
|
||||
umschalten(true);
|
||||
});
|
||||
$('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
|
||||
Knopfliste umgebaut, und die meldet ihre Wahl als change-Ereignis
|
||||
am urspruenglichen <select> -- ohne dieses Ereignis merkte das
|
||||
Formular vom Artwechsel nichts. */
|
||||
$('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
|
||||
Stand vom Oeffnen des Formulars -- also dauerhaft auf „Heute", auch
|
||||
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.');
|
||||
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();
|
||||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||||
});
|
||||
@@ -3233,7 +4022,18 @@
|
||||
if (window.Textform) {
|
||||
for (const id of ['f-text', 'f-aufgaben', 'f-regeln']) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
+200
-2
@@ -626,6 +626,11 @@
|
||||
let raeume = [];
|
||||
let offen = null; // der gerade angesehene Raum
|
||||
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 gewaehlt = new Set(); // im Neu-Dialog
|
||||
|
||||
@@ -2291,7 +2296,8 @@
|
||||
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
|
||||
einem Foto besteht, hätte sonst einen leeren Absatz darunter
|
||||
-- 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 ==========
|
||||
@@ -2335,6 +2341,17 @@
|
||||
zeit.title = wann(n.erstellt, true);
|
||||
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
|
||||
Gruppe bezieht man sich durchaus auf sich selbst. Nur auf eine
|
||||
zurueckgenommene nicht: Das Zitat waere leer. */
|
||||
@@ -2373,6 +2390,27 @@
|
||||
das kein gewöhnlicher Handgriff ist. Die Schranke steht
|
||||
ohnehin im Server; hier geht es darum, sie nicht aus Versehen
|
||||
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)) {
|
||||
const fremd = !n.selbst;
|
||||
const weg = wortKnopf('chat-nachricht__weg-knopf',
|
||||
@@ -2802,7 +2840,31 @@
|
||||
const geraet = document.createElement('audio');
|
||||
geraet.controls = true;
|
||||
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';
|
||||
/* Ein Vorleseprogramm sagt sonst nur "Audio". */
|
||||
geraet.setAttribute('aria-label',
|
||||
@@ -3379,6 +3441,105 @@
|
||||
+ `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) {
|
||||
if (!await frageNach({
|
||||
titel: fremd ? 'Diese Nachricht löschen?' : 'Nachricht löschen?',
|
||||
@@ -3574,6 +3735,43 @@
|
||||
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) => {
|
||||
let n;
|
||||
try { n = JSON.parse(e.data); } catch { return; }
|
||||
|
||||
+34
-21
@@ -235,29 +235,42 @@
|
||||
if (antwort.status === 429) {
|
||||
melde('Zu viele Versuche. In etwa 10 Minuten geht es wieder.');
|
||||
} else if (fehlversuche >= 2) {
|
||||
/* AUF DER CREW-WAND IST DIE AUSWAHL NICHT DAS PROBLEM
|
||||
(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.
|
||||
/* ==== EIN SATZ FÜR BEIDE WÄNDE (03.10.2026) ================
|
||||
|
||||
Der Satz „Stimmt die Auswahl oben?" schickte sie damit in
|
||||
eine Schleife: Sie probieren alle vier Kacheln durch,
|
||||
sammeln vier Fehlversuche, und nach acht in zehn Minuten ist
|
||||
ihre Adresse gesperrt. Ein Hinweis, der die Sperre
|
||||
herbeifuehrt, gegen die er helfen soll.
|
||||
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."
|
||||
|
||||
Auf der Agenturwand stimmt der Satz weiterhin -- dort
|
||||
entscheidet die Auswahl. Deshalb wird gefragt, auf welcher
|
||||
Wand man steht, statt den Satz ueberall zu streichen. */
|
||||
const crewWand = document.body.classList.contains('gate--crew');
|
||||
melde(crewWand
|
||||
? 'Zugangscode stimmt nicht. Achte auf die Bindestriche – '
|
||||
+ 'die Auswahl oben ist nicht schuld.'
|
||||
: 'Zugangscode stimmt nicht. Stimmt die Auswahl oben? '
|
||||
+ 'Ein Code gehört immer zu genau einer davon.');
|
||||
SIE HAT RECHT, UND DER GRUND IST MEINE EIGENE ÄNDERUNG.
|
||||
Hier stand bis eben ein eigener Satz für die Crew-Wand:
|
||||
„die Auswahl oben ist nicht schuld". Er stimmte am
|
||||
20.09.2026, weil es dort den STILLEN ZUGANG gab — die
|
||||
rechte Hand und die Modis kamen über die Codekennung
|
||||
herein, egal welche Kachel sie antippten. Der Satz
|
||||
verhinderte, dass sie alle Kacheln durchprobieren und sich
|
||||
dabei selbst aussperren.
|
||||
|
||||
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 {
|
||||
melde('Zugangscode stimmt nicht.');
|
||||
}
|
||||
|
||||
@@ -104,7 +104,99 @@
|
||||
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() {
|
||||
/* 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;
|
||||
const l = lage();
|
||||
const t = TEXTE[l];
|
||||
|
||||
@@ -659,14 +659,29 @@
|
||||
|
||||
async function eintragLoeschen(e, aufgabe) {
|
||||
const was = e.name || (e.link ? kurzLink(e.link) : tagText(e.datum));
|
||||
const frage = window.frageNach
|
||||
? 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',
|
||||
})
|
||||
: window.confirm(`„${was}" löschen?`);
|
||||
/* ==== KEIN ZWEITER NOTNAGEL (02.10.2026) =======================
|
||||
|
||||
Hier stand `window.frageNach ? … : window.confirm(…)`. Der
|
||||
Hintergedanke war richtig -- es soll etwas passieren, auch wenn
|
||||
die Datei fehlt. Nur hat `nachfrage.js` genau diesen Notnagel
|
||||
SCHON, und zwar mit demselben Text wie der richtige Dialog
|
||||
(siehe den Kopf dieser Datei: „wer den Notnagel bekommt, soll
|
||||
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;
|
||||
try {
|
||||
const a = await hole(`${BASIS}/eintrag/${e.id}`, { method: 'DELETE' });
|
||||
@@ -1128,7 +1143,7 @@
|
||||
platz.append(tabelle);
|
||||
platz.append(el('p', 'mz-ziele__gilt',
|
||||
`${leute.length} von ${d.leute.length} Personen · sortiert nach `
|
||||
+ `„${spaltenName(teamSortierung.spalte, d)}"`));
|
||||
+ `„${spaltenName(teamSortierung.spalte, d)}“`));
|
||||
} catch {
|
||||
$('team').textContent = '';
|
||||
$('team').append(el('p', 'mz-leer', 'Die Übersicht konnte nicht geladen werden.'));
|
||||
|
||||
@@ -82,10 +82,16 @@
|
||||
gleichzeitig kann der Browser ohnehin nicht, und ein neuer je
|
||||
Aufruf wuerde den Fokus verlieren. */
|
||||
var dlg = null, feld = null, tippfeld = null, zahlfeld = null, fehlerZeile = null;
|
||||
/* Das gewaehlte Bild und die Adresse seiner Vorschau. Beides lebt
|
||||
hier und nicht im Aufruf: `bauen()` haengt die Zuhoerer einmal an,
|
||||
und die muessen auch beim naechsten Oeffnen noch dasselbe meinen. */
|
||||
var bildfeld = null, gewaehltesBild = null, bildUrl = null;
|
||||
var datumfeld = null, hakenfeld = null, hakenSperrtDatum = false;
|
||||
/* Die gewaehlten Bilder. Sie leben hier und nicht im Aufruf:
|
||||
`bauen()` haengt die Zuhoerer einmal an, und die muessen auch
|
||||
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() {
|
||||
if (dlg) return dlg;
|
||||
@@ -112,6 +118,41 @@
|
||||
+ '<input id="nachfrage-zahl" type="number" min="1" inputmode="numeric" />'
|
||||
+ '<p class="nachfrage__hinweis" data-teil="zahlhinweis"></p>'
|
||||
+ '</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) ====
|
||||
|
||||
VanVan (Support): „Wenn man hier im Support auf deine Frage
|
||||
@@ -133,7 +174,7 @@
|
||||
zehn anderen Aufrufer im Haus nichts. */
|
||||
+ '<div class="nachfrage__zeile" data-teil="bildzeile" hidden>'
|
||||
+ '<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">'
|
||||
+ '<button type="button" class="knopf knopf--klein" data-teil="bildwaehlen">'
|
||||
+ 'Bild wählen</button>'
|
||||
@@ -141,7 +182,7 @@
|
||||
+ '<button type="button" class="abmelden" data-teil="bildweg" hidden>'
|
||||
+ 'Entfernen</button>'
|
||||
+ '</div>'
|
||||
+ '<img class="nachfrage__vorschau" data-teil="bildvorschau" alt="" hidden />'
|
||||
+ '<div class="nachfrage__vorschauen" data-teil="bildvorschauen" hidden></div>'
|
||||
+ '</div>'
|
||||
+ '<p class="nachfrage__fehler" data-teil="fehler" role="alert"></p>'
|
||||
+ '<div class="dialog__knoepfe">'
|
||||
@@ -153,6 +194,22 @@
|
||||
tippfeld = dlg.querySelector('#nachfrage-tipp');
|
||||
zahlfeld = dlg.querySelector('#nachfrage-zahl');
|
||||
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"]');
|
||||
|
||||
/* DIE DREI ZUHOERER NUR EINMAL. `bauen()` laeuft genau einmal --
|
||||
@@ -162,53 +219,134 @@
|
||||
teil('bildwaehlen').addEventListener('click', function () { bildfeld.click(); });
|
||||
teil('bildweg').addEventListener('click', function () {
|
||||
bildfeld.value = '';
|
||||
bildSetzen(null);
|
||||
gewaehlteBilder = [];
|
||||
bilderZeichnen();
|
||||
});
|
||||
bildfeld.addEventListener('change', function (e) {
|
||||
var datei = (e.target.files && e.target.files[0]) || null;
|
||||
/* 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 && datei.type && datei.type.indexOf('image/') !== 0) {
|
||||
fehlerZeile.textContent = 'Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.';
|
||||
bildfeld.value = '';
|
||||
bildSetzen(null);
|
||||
return;
|
||||
}
|
||||
fehlerZeile.textContent = '';
|
||||
bildSetzen(datei);
|
||||
bilderDazu(e.target.files ? Array.prototype.slice.call(e.target.files) : []);
|
||||
/* DAS FELD WIRD GELEERT, die Liste nicht. Sonst meldet der
|
||||
Browser beim zweiten Antippen DESSELBEN Bildes kein
|
||||
`change` -- er sieht keine Aenderung -- und es saehe aus, als
|
||||
waere der Knopf kaputt. */
|
||||
e.target.value = '';
|
||||
});
|
||||
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
|
||||
* seltener das falsche Bild an; dieselbe Ueberlegung wie auf der
|
||||
* Supportseite beim Melden. `createObjectURL` wird wieder
|
||||
* freigegeben -- sonst haelt jedes abgewaehlte Bild den Speicher
|
||||
* bis zum naechsten Laden der Seite. */
|
||||
function bildSetzen(datei) {
|
||||
if (bildUrl) { try { URL.revokeObjectURL(bildUrl); } catch (e) { /* egal */ } }
|
||||
bildUrl = null;
|
||||
gewaehltesBild = datei || null;
|
||||
* Supportseite beim Melden.
|
||||
*
|
||||
* JEDES BILD HAT SEIN EIGENES KREUZ, seit es mehrere sein duerfen:
|
||||
* Wer das dritte falsch erwischt hat, soll nicht alle drei neu
|
||||
* waehlen muessen.
|
||||
*
|
||||
* 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 weg = teil('bildweg');
|
||||
var schau = teil('bildvorschau');
|
||||
if (!datei) {
|
||||
var schau = teil('bildvorschauen');
|
||||
|
||||
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';
|
||||
weg.hidden = true;
|
||||
schau.hidden = true;
|
||||
schau.removeAttribute('src');
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
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 + '"]'); }
|
||||
|
||||
/** Setzt Text und blendet die Zeile aus, wenn es keinen gibt.
|
||||
@@ -265,10 +403,24 @@
|
||||
var zahlMax = o.zahlMax || 0;
|
||||
var zahlLeer = !!o.zahlLeer;
|
||||
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
|
||||
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 || '';
|
||||
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.';
|
||||
|
||||
/* ---- Ausgang 2: kein <dialog> -> confirm() mit DEMSELBEN Text ----
|
||||
@@ -304,10 +456,21 @@
|
||||
/* IM NOTAUSGANG GIBT ES KEIN BILD, und das wird gesagt statt
|
||||
verschwiegen: `window.prompt` kann keine Datei. Wer hier
|
||||
landet, hat einen Browser ohne `<dialog>` -- er kann
|
||||
antworten, nur eben ohne Anhang. `bild: null` sorgt dafuer,
|
||||
dass die aufrufende Stelle nicht raten muss. */
|
||||
if (bild) erg.bild = null;
|
||||
return Promise.resolve((grund || zahl || eingabe || bild) ? erg : true);
|
||||
antworten, nur eben ohne Anhang. Die LEERE LISTE sorgt
|
||||
dafuer, dass die aufrufende Stelle nicht raten muss -- und
|
||||
dass sie dieselbe Form bekommt wie im Normalfall, statt
|
||||
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();
|
||||
@@ -348,6 +511,19 @@
|
||||
if (zahlMax) zahlfeld.max = String(zahlMax); else zahlfeld.removeAttribute('max');
|
||||
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;
|
||||
if (bild) {
|
||||
teil('bildschild').textContent = bild;
|
||||
@@ -355,7 +531,8 @@
|
||||
/* JEDES OEFFNEN FAENGT LEER AN. Ohne diese Zeile haengt beim
|
||||
zweiten Mal noch das Bild vom ersten dran -- und niemand
|
||||
sieht es, weil der Dialog oben schon zu war. */
|
||||
bildSetzen(null);
|
||||
gewaehlteBilder = [];
|
||||
bilderZeichnen();
|
||||
}
|
||||
fehlerZeile.textContent = '';
|
||||
|
||||
@@ -424,12 +601,31 @@
|
||||
}
|
||||
erg2.zahl = n;
|
||||
}
|
||||
/* `null`, wenn keins gewaehlt wurde -- und nicht `undefined`.
|
||||
Die aufrufende Stelle fragt dann `antwort.bild`, bekommt
|
||||
eine klare Absage und muss nicht wissen, ob das Feld
|
||||
ueberhaupt angeboten wurde. */
|
||||
if (bild) erg2.bild = gewaehltesBild || null;
|
||||
antwort = (grund || zahl || eingabe || bild) ? erg2 : true;
|
||||
/* IMMER EINE LISTE, auch wenn sie leer ist -- und nicht
|
||||
`undefined`. Die aufrufende Stelle schleift darueber und
|
||||
muss weder wissen, ob das Feld angeboten wurde, noch den
|
||||
Sonderfall „genau eines" kennen. */
|
||||
/* DAS DATUM WIRD GEPRUEFT, BEVOR DER DIALOG ZUGEHT. Ein
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -611,6 +611,26 @@ Der Code gilt einmal fuer die `
|
||||
werte.append(zuletzt);
|
||||
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
|
||||
Name -- der steht als eigene Zeile unter dem Band. */
|
||||
if (p.rolle === 'scout' && p.manager_name) {
|
||||
|
||||
@@ -66,10 +66,6 @@
|
||||
darüber -- sonst stünden irgendwann zwei verschiedene Wahrheiten
|
||||
übereinander auf einem Bildschirm. */
|
||||
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_heute: 'Aufgaben',
|
||||
aufgaben_review: 'Aufgaben',
|
||||
@@ -1513,16 +1509,22 @@
|
||||
/* HINWEISE OHNE ZAHL (19.09.2026).
|
||||
|
||||
Vor jedem Hinweis steht seine Anzahl -- das ist richtig, solange
|
||||
er Dinge zaehlt ("3 Aufgaben sind ueberfaellig"). „Bei dir
|
||||
klingelt nichts" zaehlt aber nichts, es ist ein Zustand. Eine „1“
|
||||
davor liest sich wie ein Zaehlfehler.
|
||||
er Dinge zaehlt ("3 Aufgaben sind ueberfaellig"). Ein Hinweis,
|
||||
der einen ZUSTAND beschreibt, zaehlt nichts; eine „1“ davor
|
||||
liest sich dort wie ein Zaehlfehler.
|
||||
|
||||
Als SATZ und nicht als Sonderfall im Aufbau: Wer hier einen
|
||||
weiteren Zustand ergaenzt, traegt ihn in diese eine Liste ein. */
|
||||
const OHNE_ZAHL = new Set(['kein_geraet']);
|
||||
Die Liste ist seit dem 03.10.2026 leer: Ihr einziger Eintrag war
|
||||
„kein_geraet" („Bei dir klingelt nichts"), und der steht jetzt
|
||||
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 = {
|
||||
kein_geraet: () => 'Bei dir klingelt nichts – kein Gerät angemeldet',
|
||||
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_review: (n) => `${n} ${n === 1 ? 'Aufgabe wartet' : 'Aufgaben warten'} auf Freigabe`,
|
||||
|
||||
+435
-103
@@ -33,7 +33,25 @@
|
||||
let leitung = false;
|
||||
let meldungen = [];
|
||||
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
|
||||
daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */
|
||||
@@ -109,51 +127,140 @@
|
||||
MELDEN
|
||||
=================================================================== */
|
||||
|
||||
function bildZeigen(datei) {
|
||||
gewaehltesBild = datei || null;
|
||||
/** Die Auswahl neu zeichnen — Name, Knöpfe, Vorschaubilder.
|
||||
*
|
||||
* 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 weg = $('bild-weg');
|
||||
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;
|
||||
vorschau.hidden = true;
|
||||
$('vorschau-bild').removeAttribute('src');
|
||||
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;
|
||||
/* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server: Sie
|
||||
ist noch gar nicht hochgeladen. `URL.createObjectURL` zeigt das
|
||||
Bild aus dem Arbeitsspeicher — wer sieht, was er anhängt, hängt
|
||||
nicht versehentlich das falsche an. */
|
||||
const url = URL.createObjectURL(datei);
|
||||
const img = $('vorschau-bild');
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
|
||||
for (const [i, datei] of gewaehlteBilder.entries()) {
|
||||
const kasten = el('div', 's-vorschau__eins');
|
||||
/* DIE VORSCHAU KOMMT AUS DER DATEI SELBST, nicht vom Server:
|
||||
Sie ist noch gar nicht hochgeladen. `URL.createObjectURL`
|
||||
zeigt das Bild aus dem Arbeitsspeicher — wer sieht, was er
|
||||
anhängt, hängt nicht versehentlich das falsche an. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
$('bild-waehlen').addEventListener('click', () => $('bild').click());
|
||||
$('bild-weg').addEventListener('click', () => {
|
||||
$('bild').value = '';
|
||||
bildZeigen(null);
|
||||
gewaehlteBilder = [];
|
||||
bilderZeigen();
|
||||
});
|
||||
$('bild').addEventListener('change', (e) => {
|
||||
const datei = e.target.files?.[0] || null;
|
||||
if (!datei) { bildZeigen(null); return; }
|
||||
/* 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) {
|
||||
melde('melden-fehler', 'Das Bild ist größer als 12 MB – bitte ein kleineres.');
|
||||
$('bild').value = '';
|
||||
bildZeigen(null);
|
||||
return;
|
||||
}
|
||||
melde('melden-fehler', '');
|
||||
bildZeigen(datei);
|
||||
bilderDazu([...(e.target.files || [])]);
|
||||
/* DAS FELD WIRD GELEERT, die Liste nicht. Sonst meldet der
|
||||
Browser beim zweiten Antippen DESSELBEN Bildes kein
|
||||
`change` — er sieht keine Änderung — und es sähe aus, als
|
||||
wäre der Knopf kaputt. */
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
$('melden').addEventListener('submit', async (e) => {
|
||||
@@ -169,14 +276,47 @@
|
||||
melde('melden-fehler', '');
|
||||
$('melden-hinweis').textContent = 'wird geschickt …';
|
||||
try {
|
||||
/* DER TEXT REIST IM KOPF, das Bild im Rumpf — derselbe Weg wie
|
||||
bei Chat-Anhängen. Ein Weg für beides, damit es keinen
|
||||
Zustand „Meldung da, Bild fehlt noch" gibt. */
|
||||
const kopf = { 'x-text': encodeURIComponent(text), 'x-seite': location.pathname };
|
||||
/* DER TEXT REIST IM KOPF, die Bilder im Rumpf — derselbe Weg
|
||||
wie bei Chat-Anhängen. Ein Weg für beides, damit es keinen
|
||||
Zustand „Meldung da, Bild fehlt noch" gibt.
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: kopf,
|
||||
body: gewaehltesBild || new Blob([]),
|
||||
body: new Blob(gewaehlteBilder),
|
||||
});
|
||||
if (!a.ok) {
|
||||
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
|
||||
@@ -184,7 +324,8 @@
|
||||
}
|
||||
$('text').value = '';
|
||||
$('bild').value = '';
|
||||
bildZeigen(null);
|
||||
gewaehlteBilder = [];
|
||||
bilderZeigen();
|
||||
$('melden-hinweis').textContent = 'Danke – ist angekommen.';
|
||||
setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000);
|
||||
await laden();
|
||||
@@ -201,28 +342,81 @@
|
||||
EINE MELDUNG ALS KARTE
|
||||
=================================================================== */
|
||||
|
||||
function bildAnhaengen(karte, m) {
|
||||
if (!m.bild) return;
|
||||
/** Welchen Farbton hat Runde n?
|
||||
*
|
||||
* ==== 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 a = el('a');
|
||||
a.href = m.bild;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.title = 'In voller Größe ansehen';
|
||||
const img = document.createElement('img');
|
||||
img.src = m.bild;
|
||||
img.alt = 'Bild zur Meldung';
|
||||
img.loading = 'lazy';
|
||||
/* 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 (m.bild_breite > 0 && m.bild_hoehe > 0) {
|
||||
img.width = m.bild_breite;
|
||||
img.height = m.bild_hoehe;
|
||||
if (liste.length > 1) kasten.dataset.viele = 'ja';
|
||||
for (const [i, b] of liste.entries()) {
|
||||
const a = el('a');
|
||||
a.href = b.weg;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.title = 'In voller Größe ansehen';
|
||||
const img = el('img');
|
||||
img.src = b.weg;
|
||||
img.alt = liste.length > 1
|
||||
? `${wortDazu} – Bild ${i + 1} von ${liste.length}`
|
||||
: wortDazu;
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
/* 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);
|
||||
kasten.append(a);
|
||||
karte.append(kasten);
|
||||
wohin.append(kasten);
|
||||
}
|
||||
|
||||
function karteBauen(m) {
|
||||
@@ -245,7 +439,7 @@
|
||||
/* `textContent` mit erhaltenen Zeilenumbrüchen (das CSS setzt
|
||||
`white-space: pre-wrap`). Kein innerHTML, siehe der Kopf. */
|
||||
k.append(el('p', 's-karte__text', m.text));
|
||||
bildAnhaengen(k, m);
|
||||
bilderAnhaengen(k, m.bilder, 'Bild zur Meldung');
|
||||
|
||||
if (leitung && m.seite) {
|
||||
const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite);
|
||||
@@ -270,47 +464,164 @@
|
||||
als der alte Satz. */
|
||||
const verlauf = Array.isArray(m.verlauf) ? m.verlauf : [];
|
||||
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');
|
||||
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');
|
||||
runde.style.setProperty('--ton', String(rundenTon(r.nr)));
|
||||
if (r.urteil) runde.dataset.urteil = r.urteil;
|
||||
|
||||
const kopfR = el('div', 's-runde__kopf');
|
||||
kopfR.append(el('span', 's-runde__nr', 'Runde ' + r.nr));
|
||||
if (r.von) kopfR.append(el('span', 's-runde__von', r.von));
|
||||
runde.append(kopfR);
|
||||
runde.append(el('p', 's-runde__antwort', r.antwort));
|
||||
if (r.urteil) {
|
||||
const u = el('p', 's-runde__urteil');
|
||||
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);
|
||||
if (r.am) {
|
||||
const wann = el('span', 's-runde__zeit', herZeit(r.am));
|
||||
wann.title = genau(r.am);
|
||||
kopfR.append(wann);
|
||||
}
|
||||
/* ==== DAS BILD ZUR ANTWORT (02.10.2026) ==================
|
||||
runde.append(kopfR);
|
||||
|
||||
Es steht BEI DER RUNDE, zu der es gehört, nicht oben bei
|
||||
der Meldung. Nach drei Runden ist sonst nicht mehr zu
|
||||
sehen, welches Bild zu welchem Versuch gehörte -- und
|
||||
genau diese Frage löst einen wiederkehrenden Fehler.
|
||||
/* (1) WAS DIE LEITUNG GETAN HAT. */
|
||||
const vonOben = el('div', 's-schritt');
|
||||
vonOben.dataset.wer = 'leitung';
|
||||
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
|
||||
das Bild der Meldung weiter oben. Ein Bildschirmfoto in
|
||||
Briefmarkengröße beantwortet keine Frage. */
|
||||
if (r.urteil_bild) {
|
||||
const a = el('a', 's-runde__bild');
|
||||
a.href = r.urteil_bild;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
const img = el('img');
|
||||
img.src = r.urteil_bild;
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = 'Bildschirmfoto zur Antwort in Runde ' + r.nr;
|
||||
a.append(img);
|
||||
runde.append(a);
|
||||
/* (2) WAS DER MELDER DARAUFHIN GESAGT HAT -- und die Bilder
|
||||
dazu gehoeren in DIESEN Kasten, nicht unter die Runde:
|
||||
Sie sind sein Beleg, nicht ihrer. */
|
||||
if (r.urteil) {
|
||||
const vonUnten = el('div', 's-schritt');
|
||||
vonUnten.dataset.wer = 'melder';
|
||||
vonUnten.dataset.urteil = r.urteil;
|
||||
vonUnten.append(el('span', 's-schritt__schild',
|
||||
r.urteil === 'geht' ? 'Geht wieder' : 'Ging noch nicht'));
|
||||
if (r.urteil_text) vonUnten.append(el('p', 's-schritt__text', r.urteil_text));
|
||||
bilderAnhaengen(vonUnten, r.bilder,
|
||||
'Bildschirmfoto zur Antwort in Runde ' + r.nr);
|
||||
runde.append(vonUnten);
|
||||
} else {
|
||||
/* 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);
|
||||
}
|
||||
/* 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);
|
||||
} else if (m.antwort) {
|
||||
const ant = el('div', 's-antwort');
|
||||
@@ -375,11 +686,17 @@
|
||||
neues auf, hilft das alte Bild niemandem.
|
||||
|
||||
NIE PFLICHT. Wer keines hat, antwortet ohne -- ein
|
||||
Bildschirmfoto ist Hilfe, keine Hürde. */
|
||||
bild: 'Bildschirmfoto dazu (freiwillig)',
|
||||
Bildschirmfoto ist Hilfe, keine Hürde.
|
||||
|
||||
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;
|
||||
await rueckmelden(m, false, antwort.grund || '', antwort.bild || null);
|
||||
await rueckmelden(m, false, antwort.grund || '', antwort.bilder || []);
|
||||
});
|
||||
reihe.append(nicht);
|
||||
return reihe;
|
||||
@@ -395,24 +712,28 @@
|
||||
* OHNE BILD IST DER RUMPF LEER, und das ist zulässig. Dann steht
|
||||
* 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', '');
|
||||
const anhang = Array.isArray(bilder) ? bilder : (bilder ? [bilder] : []);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
/* Der Typ sagt, was im RUMPF liegt. Ohne Bild ist das
|
||||
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([]),
|
||||
headers: kopf,
|
||||
body: new Blob(anhang),
|
||||
});
|
||||
/* DURCH DIESELBE UEBERSETZUNG WIE ALLES ANDERE (25.09.2026,
|
||||
gefunden von pruef-meldungen). Hier stand `f.fehler` roh --
|
||||
@@ -622,6 +943,17 @@
|
||||
const r = await a.json();
|
||||
leitung = r.leitung === true;
|
||||
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. */
|
||||
$('eingang-block').hidden = !leitung;
|
||||
|
||||
@@ -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') {
|
||||
document.addEventListener('DOMContentLoaded', los);
|
||||
} else { los(); }
|
||||
document.addEventListener('DOMContentLoaded', losSicher);
|
||||
} else { losSicher(); }
|
||||
})();
|
||||
|
||||
@@ -177,6 +177,14 @@
|
||||
let absagen = null;
|
||||
let darfEntscheidenV = 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 vStufe = 'anfaenger';
|
||||
|
||||
@@ -452,6 +460,7 @@
|
||||
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
|
||||
k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer));
|
||||
fuss.append(k);
|
||||
fuss.append(anpassenKnopf(v, { art: 'aufgabe', nr }, fuer, darfVerteilenV));
|
||||
}
|
||||
karte.append(fuss);
|
||||
box.append(karte);
|
||||
@@ -558,12 +567,25 @@
|
||||
Absage daneben wäre eine Liste, die nur schlechte Nachrichten
|
||||
sammelt. */
|
||||
if (antworten.length) {
|
||||
const band = el('div', 'vorlagenbrett__antwort');
|
||||
band.append(el('p', 'vorlagenbrett__antwort-schild',
|
||||
antworten.length === 1
|
||||
? 'Antwort auf deine Bewerbung:'
|
||||
: `${antworten.length} Antworten auf deine Bewerbungen:`));
|
||||
for (const a2 of antworten) {
|
||||
/* ==== GELESEN RUTSCHT NACH UNTEN (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."
|
||||
|
||||
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');
|
||||
z.dataset.zustand = a2.zustand;
|
||||
z.append(el('span', 'vorlagenbrett__antwort-wort',
|
||||
@@ -577,9 +599,98 @@
|
||||
'„' + a2.entscheid_text + '“'
|
||||
+ (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);
|
||||
/* 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) {
|
||||
/* SCHON BEWORBEN. Der Satz sagt, WER antwortet -- sonst wartet
|
||||
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) {
|
||||
knopf.disabled = true;
|
||||
const vorher = knopf.textContent;
|
||||
|
||||
@@ -411,8 +411,20 @@
|
||||
Ohne Gegenmassnahme waere jede Zuteilung fuer immer auf
|
||||
„angenommen" stehen geblieben. Deshalb zieht der Statuswechsel
|
||||
die Zuteilung jetzt mit (`zuteilungenNachStatus` im Server).
|
||||
Der Weg `/mein-stand` bleibt bestehen -- er ist die Schranke,
|
||||
falls ihn jemand direkt anspricht. */
|
||||
NACHTRAG 03.10.2026 -- UND HIER STAND EINE AUSREDE. Woertlich:
|
||||
„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.
|
||||
|
||||
|
||||
+19
-19
@@ -12,19 +12,19 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -430,22 +430,22 @@
|
||||
<!-- TEXTFORM ZUERST (21.09.2026): Die Seite fragt beim Zeichnen nach
|
||||
`window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne
|
||||
Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. -->
|
||||
<script src="assets/js/textform.js?v=202610021752"></script>
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/textform.js?v=202610051932"></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/zuteilung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/zuteilung.js?v=202610051932" defer></script>
|
||||
<!-- Der Vorlagenkatalog stand bis zum 23.09.2026 mitten in
|
||||
aufgaben.js. Er bedient jetzt zwei Seiten und hat deshalb eine
|
||||
eigene Datei; hier zeigt er den Creator-Katalog.
|
||||
Vor aufgaben.js, weil die Seite ihn beim Start einrichtet. -->
|
||||
<script src="assets/js/vorlagenbrett.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/vorlagenbrett.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -168,15 +168,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/automation.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/automation.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-17
@@ -7,14 +7,14 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -197,16 +197,16 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/befinden.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/befinden.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-22
@@ -12,24 +12,24 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202610051932" />
|
||||
<!-- Wegen des Blocks „Willst du dazugehoeren?" auf dem Brett
|
||||
„Mitmachen". Eine eigene Kopie der Regel hier waere die zweite
|
||||
Stelle, an der dieselbe Sache anders aussieht. -->
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -128,6 +128,13 @@
|
||||
<div id="feld-ende" hidden>
|
||||
<label class="feld-schild" for="f-ende">Bis</label>
|
||||
<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>
|
||||
<!-- ZU WELCHEM ACCOUNT GEHOERT DAS? (20.09.2026)
|
||||
|
||||
@@ -226,6 +233,13 @@
|
||||
<textarea id="f-regeln" rows="5" maxlength="6000"
|
||||
placeholder="z. B. · Nur Streams auf dem eigenen Konto · Punkte zählen bis 23:59 am letzten Tag"></textarea>
|
||||
</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>
|
||||
<div class="neu__knoepfe">
|
||||
<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
|
||||
`window.Textform`. Kaeme sie spaeter, waere der erste Eintrag ohne
|
||||
Gestaltung. Kein `defer`, damit sie wirklich vorher dasteht. -->
|
||||
<script src="assets/js/textform.js?v=202610021752"></script>
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/textform.js?v=202610051932"></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/ampel.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/schulung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+19
-19
@@ -7,17 +7,17 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/treff.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/treff.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- 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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -108,15 +108,15 @@
|
||||
</main>
|
||||
|
||||
<!-- 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/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/auskunft.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bewerben.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/auskunft.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bewerben.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-18
@@ -7,16 +7,16 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/bewerbung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- 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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -80,16 +80,16 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/auskunft.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bewerbungen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/auskunft.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bewerbungen.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
<!-- DIE WERBEEINBLENDUNG. Dieselbe Datei wie in der Reaction-Seite:
|
||||
Zwei Fassungen altern unterschiedlich, und dann sieht der Stream
|
||||
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=202610051932" />
|
||||
</head>
|
||||
<body class="obs-buehne">
|
||||
<div id="platz"></div>
|
||||
<script src="assets/js/werbung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/buehne.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/werbung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/buehne.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-17
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -89,15 +89,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/ki.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/calls.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/ki.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/calls.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-20
@@ -12,26 +12,26 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<!-- aufgaben.css bringt die Dialog- und Formularstile mit (.dialog,
|
||||
.neu__zeile, .dialog__knoepfe). Sie stehen dort seit dem
|
||||
Aufgabenbrett; sie hierher zu kopieren hiesse, sie an zwei Stellen
|
||||
zu pflegen. Dieselbe Lösung nutzen kalender.html und content.html.
|
||||
Gemeldet von pruef-css-klassen.mjs, bevor jemand einen ungestylten
|
||||
Dialog zu sehen bekam. -->
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/chat.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -739,24 +739,24 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/bereiche.js?v=202610021752"></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932"></script>
|
||||
<!-- TEXTFORM VOR ALLEM ANDEREN (21.09.2026): chat.js und bereich.js
|
||||
fragen beim Zeichnen nach `window.Textform`. Kaeme sie spaeter,
|
||||
waere die erste gezeichnete Nachricht ohne Gestaltung -- und das
|
||||
faellt nur dem auf, der genau hinsieht. KEIN `defer`, damit sie
|
||||
wirklich vor den anderen dasteht. -->
|
||||
<script src="assets/js/textform.js?v=202610021752"></script>
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752"></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752"></script>
|
||||
<script src="assets/js/textform.js?v=202610051932"></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932"></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932"></script>
|
||||
<!-- ANRUF VOR CHAT (18.09.2026): anruf.js stellt `window.anrufStarten`
|
||||
bereit, und chat.js baut die Knoepfe nur, wenn es das gibt.
|
||||
Andersherum waere die Reihenfolge eine stille Abhaengigkeit --
|
||||
die Knoepfe fehlten, und niemand wuesste warum. -->
|
||||
<script src="assets/js/anruf.js?v=202610021752"></script>
|
||||
<script src="assets/js/anruf.js?v=202610051932"></script>
|
||||
<!-- DIE BILDSCHAU (25.09.2026). Eigene Datei, weil sie nichts vom
|
||||
Chat weiss: Sie bekommt einen Weg und einen Namen und zeigt ein
|
||||
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
|
||||
wie frueher in den Tab. Ein Fehler, der nur beim ERSTEN Mal
|
||||
auftritt, ist der, den man nie nachstellen kann. -->
|
||||
<script src="assets/js/bildschau.js?v=202610021752"></script>
|
||||
<script src="assets/js/chat.js?v=202610021752"></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bildschau.js?v=202610051932"></script>
|
||||
<script src="assets/js/chat.js?v=202610051932"></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-17
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/content.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -179,15 +179,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/content.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/checkliste.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/content.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,15 +36,15 @@
|
||||
<link rel="preload" as="image" href="assets/img/buehne/crew-gate-hoch-580.webp"
|
||||
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="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/crew-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/crew-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<!-- Diese Wand steht NUR auf der Adresse von Team Dogi (ueberall sonst
|
||||
404, siehe crewWeiche). Sie darf das Haus deshalb direkt nennen
|
||||
statt ueber den Platzhalter haus.css zu gehen -- und sie MUSS es:
|
||||
Eine Zugangswand, die sich erst nach dem Umbiegen faerbt, waere
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="gate gate--crew">
|
||||
@@ -459,7 +459,7 @@
|
||||
|
||||
|
||||
|
||||
<script src="assets/js/gate.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/gate.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -23,25 +23,25 @@
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-192.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/crew-192.png?v=202610051932",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-512.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/crew-512.png?v=202610051932",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/crew-192-maskable.png?v=202610051932",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202610021752",
|
||||
"src": "/assets/img/app-symbole/crew-512-maskable.png?v=202610051932",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
+17
-17
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -108,15 +108,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-18
@@ -7,14 +7,14 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/entwicklung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -306,20 +306,20 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610051932" defer></script>
|
||||
<!-- VOR entwicklung.js: Die Seite richtet den Katalog beim Start ein,
|
||||
also muss es ihn zu dem Zeitpunkt geben. Mit "defer" laufen beide
|
||||
in Dokumentreihenfolge. -->
|
||||
<script src="assets/js/vorlagenbrett.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/entwicklung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/vorlagenbrett.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/entwicklung.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-15
@@ -7,13 +7,13 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/hilfe.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/hilfe.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -146,14 +146,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hilfe.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hilfe.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
<link rel="preload" as="image" href="assets/img/buehne/gate-hoch-580.webp"
|
||||
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="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -427,7 +427,7 @@
|
||||
|
||||
|
||||
|
||||
<script src="assets/js/gate.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/gate.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-17
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -383,15 +383,15 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kalender-abo.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/leistung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -444,13 +444,13 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752"></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752"></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752"></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752"></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752"></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752"></script>
|
||||
<script src="assets/js/leistung.js?v=202610021752"></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932"></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932"></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932"></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932"></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932"></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932"></script>
|
||||
<script src="assets/js/leistung.js?v=202610051932"></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/manager-ziele.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/manager-ziele.css?v=202610051932" />
|
||||
<!-- module.css an VORLETZTER, haus.css an LETZTER Stelle. Die
|
||||
Reihenfolge ist keine Geschmacksfrage: haus.css setzt nur
|
||||
Variablen, und bei Variablen entscheidet allein, wer zuletzt
|
||||
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/haus.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -218,16 +218,16 @@
|
||||
kopf.js liest daraus die Leitungsrollen. Ohne sie stürzt jede
|
||||
Seite ab, und zwar nur in der Browserkonsole -- die Kopfleiste
|
||||
kommt trotzdem. -->
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<!-- wahl.js gehört zu kopf.js: Der Sicht-Umschalter baut daraus seine
|
||||
Liste. pruef-css-klassen setzt das durch, und zwar auch die
|
||||
Reihenfolge -- kopf.js fragt beim Aufbau nach dem fertigen
|
||||
Bedienelement. -->
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/manager-ziele.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/manager-ziele.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,13 +12,13 @@
|
||||
vergessen wird. -->
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/material.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/material.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
<body class="start" data-ton="42" data-buehne="studio">
|
||||
|
||||
@@ -138,14 +138,14 @@
|
||||
<p class="m-laedt" id="laedt">Einen Moment …</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/material.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/material.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+14
-14
@@ -7,13 +7,13 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/notizen.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/notizen.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start" data-ton="46">
|
||||
@@ -181,12 +181,12 @@
|
||||
|
||||
<!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js
|
||||
liest daraus die Leitungsrollen. -->
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/notizen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/notizen.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -453,14 +453,14 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/personen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/personen.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-18
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -214,16 +214,16 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/hochladen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/profil.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/steckbrief.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/profil.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+19
-19
@@ -7,15 +7,15 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/reaktion.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/spendenkarte.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/werbung.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/reaktion.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/spendenkarte.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/werbung.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start reaktion-seite" data-stand="laedt">
|
||||
@@ -905,15 +905,15 @@
|
||||
|
||||
<!-- bereiche.js VOR kopf.js: Dort steht `window.Bereiche`, und kopf.js
|
||||
liest daraus die Leitungsrollen. -->
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/spendenkarte.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/werbung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/reaktion.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/spendenkarte.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/werbung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/reaktion.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+17
-17
@@ -7,14 +7,14 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="manifest" href="/workspace/app.webmanifest" />
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/treff.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/treff.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/haus.css?v=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -134,14 +134,14 @@
|
||||
Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen
|
||||
Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen,
|
||||
die seit dem 07.09. jede Seite danach absucht. -->
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/rechte.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/rechte.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+18
-18
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -106,17 +106,17 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/ki.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/report.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/ki.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/holen.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/report.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-16
@@ -12,20 +12,20 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -173,14 +173,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+35
-16
@@ -12,21 +12,21 @@
|
||||
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
|
||||
eine Flaeche sind statt zweier. -->
|
||||
<meta name="theme-color" content="#06090f" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610021752" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610021752" />
|
||||
<link rel="icon" type="image/png" href="/assets/img/app-symbole/workspace-32.png?v=202610051932" />
|
||||
<link rel="apple-touch-icon" href="/assets/img/app-symbole/workspace-180.png?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202610051932" />
|
||||
<!-- Nur die Startseite: Begrüßungskachel, Uhr, Glockenmulde. Siehe
|
||||
den Hinweis in start.css -- dort stand es bis zum 07.09.2026. -->
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610021752" />
|
||||
<link rel="stylesheet" href="assets/css/heim.css?v=202610051932" />
|
||||
<link rel="stylesheet" href="assets/css/module.css?v=202610051932" />
|
||||
<!-- haus.css MUSS die letzte Stilvorlage sein (10.09.2026).
|
||||
Auf der Agenturadresse ist sie leer -- das Haus hier IST der
|
||||
Grundzustand. Auf der Adresse von Team Dogi biegt der Server
|
||||
genau diesen Dateinamen auf crew-haus.css um, und die Seite
|
||||
kommt schon beim ERSTEN Abruf in Lila und Babyblau an --
|
||||
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=202610051932" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -622,6 +622,25 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==== "Du bekommst gerade nichts" (03.10.2026) =================
|
||||
|
||||
Am 03.10. nachgemessen: 20 aktive Personen, 8 mit einer
|
||||
Anmeldung. Zwoelf bekamen keine einzige Benachrichtigung --
|
||||
darunter die linke Hand und ein Modi. Die Glocke sagt es nur
|
||||
dem, der sie anschaut, und genau das hatten diese zwoelf nie
|
||||
getan.
|
||||
|
||||
NUR HIER, NICHT AUF 39 SEITEN. glocke.js laeuft ueberall; der
|
||||
Hinweis erscheint nur dort, wo die Seite ihm einen Platz gibt
|
||||
-- wie schon bei `#glocke-platz`. Stuende er auf jeder Seite,
|
||||
waere er nach dem zweiten Mal Tapete.
|
||||
|
||||
LEER UND OHNE RAHMEN, bis glocke.js ihn fuellt: Wer die Glocke
|
||||
schon an hat (oder wessen Browser gar nicht kann), soll hier
|
||||
kein leeres Kaestchen sehen. Laeuft das Skript nicht, bleibt
|
||||
die Seite genau so, wie sie vorher war. -->
|
||||
<div id="push-hinweis"></div>
|
||||
|
||||
<!-- Was ist dran? Steht VOR den Zahlen, weil es der Unterschied
|
||||
zwischen "wie viel liegt an" und "was muss ich jetzt tun" ist.
|
||||
Bleibt komplett weg, wenn nichts anliegt. -->
|
||||
@@ -732,16 +751,16 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/meldung.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/meldung.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/nachfrage.js?v=202610051932" defer></script>
|
||||
|
||||
|
||||
<script src="assets/js/wahl.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/live.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/start.js?v=202610021752" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/bereiche.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/installieren.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/glocke.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/live.js?v=202610051932" defer></script>
|
||||
<script src="assets/js/start.js?v=202610051932" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user