Files
dogfather-universe/workspace/assets/js/support.js
T
DogFatherGitandClaude Opus 5 b31bd9b515 Zwei bis drei Bilder je Supportmeldung -- und zwei Funde unterwegs
VanVan im Support, Meldung #11, VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufuegen bei einer
Meldung. 2-3 waeren besser." Und in der zweiten Runde der Satz, auf
den es ankommt: „wenn man es nacheinander versucht hinzuzufuegen wird
das Bild immer nur ersetzt."

EINE TABELLE STATT NEUER SPALTEN

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

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

DREI BILDER IN EINER ANFRAGE

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

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

ZWEI FUNDE, DIE DIE PRUEFUNG GEMACHT HAT UND NICHT ICH

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

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

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

DREI WEITERE ROTE ZEILEN, DIE NICHT ZU DIESEM UMBAU GEHOERTEN

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

GEPRUEFT

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 23:38:03 +02:00

808 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
SUPPORT (24.09.2026)
Filipe: „jeder kann da probleme von der seite melden und hinweisen.
fotos mit schicken mit einem text. … so einfach wie möglich, will
nichts kompliziertes oder zu viel. kurz und knapp."
EINE SEITE, ZWEI ANSICHTEN. Wer meldet, sieht ein Feld und seine
eigenen Meldungen. DogFather und die rechte Hand sehen den Eingang:
alles, nach Stand sortiert, mit Bild und zwei Knöpfen.
WELCHE ANSICHT GILT, ENTSCHEIDET DER SERVER. Er schickt `leitung`
in der Antwort — diese Datei fragt nicht nach der Rolle. Eine
Rollenprüfung im Browser wäre eine zweite Stelle mit derselben
Regel, und es wäre die, an der man sie umgehen kann.
KEIN innerHTML MIT FREMDEM TEXT. Alles, was jemand geschrieben hat,
geht durch `textContent`. Hier landet Text aus einem Fehlerbericht —
und genau dort steht irgendwann ein `<script>`, weil jemand es
ausprobiert.
===================================================================== */
(() => {
'use strict';
const $ = (id) => document.getElementById(id);
const el = (was, klasse, text) => {
const e = document.createElement(was);
if (klasse) e.className = klasse;
if (text !== undefined) e.textContent = text;
return e;
};
let leitung = false;
let meldungen = [];
let filter = 'offen';
/* ==== 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;
/* Anzeige-Wörter, keine Regel. Der Server schickt den Schlüssel; was
daraus auf dem Bildschirm wird, ist eine Frage der Sprache. */
const STAND_WORT = {
neu: 'neu',
in_arbeit: 'wird bearbeitet',
/* ==== DER VIERTE STAND (25.09.2026) ==========================
Filipe: „damit die bescheid wissen und dan anklicken koennen,
es funktioniert, oder noch nicht."
Das Wort sagt, WER DRAN IST -- nicht, was passiert ist. „Wartet
auf Antwort" waere aus Sicht der Leitung richtig und aus Sicht
des Melders falsch: Er wartet nicht, er ist gefragt. */
wartet: 'geht es wieder?',
erledigt: 'erledigt',
};
function melde(id, text) {
const f = $(id);
if (!f) return;
f.textContent = text || '';
f.hidden = !text;
}
async function hole(pfad, einstellungen) {
const a = await fetch(pfad, { credentials: 'same-origin', ...einstellungen });
if (a.status === 401) { location.href = 'index.html'; throw new Error('weg'); }
return a;
}
/** Was der Server als Fehler geschickt hat — oder ein Ersatz.
*
* Die Antwort wegzuwerfen und „Ging nicht" zu schreiben schickt
* einen auf die Suche nach etwas, das der Server längst gesagt hat. */
async function fehlerText(a, ersatz) {
try {
const j = await a.json();
return typeof j?.fehler === 'string' && j.fehler ? j.fehler : ersatz;
} catch { return ersatz; }
}
/** „vor 3 Tagen" statt eines Zeitstempels.
*
* Bei einer Meldung ist die Frage „wie lange liegt das schon?",
* nicht „wann genau war das?". Das genaue Datum bleibt als Titel. */
function herZeit(iso) {
if (!iso) return '';
const ms = Date.now() - Date.parse(iso);
if (!Number.isFinite(ms)) return '';
const min = Math.floor(ms / 60000);
if (min < 2) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const std = Math.floor(min / 60);
if (std < 24) return `vor ${std} Std.`;
const tage = Math.floor(std / 24);
if (tage === 1) return 'gestern';
if (tage < 31) return `vor ${tage} Tagen`;
const monate = Math.floor(tage / 30.44);
return monate < 12 ? `vor ${monate} Mon.` : 'vor über einem Jahr';
}
function genau(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const z = (n) => String(n).padStart(2, '0');
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
+ `${z(d.getHours())}:${z(d.getMinutes())}`;
}
/* ===================================================================
MELDEN
=================================================================== */
/** 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');
/* 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) {
name.textContent = '';
weg.hidden = true;
vorschau.hidden = true;
return;
}
const kb = gewaehlteBilder.reduce((s, d) => s + d.size, 0) / 1024;
name.textContent = gewaehlteBilder.length === 1
? `${gewaehlteBilder[0].name} · ${Math.round(kb)} KB`
: `${gewaehlteBilder.length} von ${bilderMax} Bildern · ${Math.round(kb)} KB`;
weg.hidden = false;
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 = '';
gewaehlteBilder = [];
bilderZeigen();
});
$('bild').addEventListener('change', (e) => {
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) => {
e.preventDefault();
const text = $('text').value.trim();
if (text.length < 5) {
melde('melden-fehler', 'Schreib bitte kurz, was los ist – ein Satz reicht.');
$('text').focus();
return;
}
const knopf = $('senden');
knopf.disabled = true;
melde('melden-fehler', '');
$('melden-hinweis').textContent = 'wird geschickt …';
try {
/* 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: new Blob(gewaehlteBilder),
});
if (!a.ok) {
melde('melden-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
$('text').value = '';
$('bild').value = '';
gewaehlteBilder = [];
bilderZeigen();
$('melden-hinweis').textContent = 'Danke – ist angekommen.';
setTimeout(() => { $('melden-hinweis').textContent = ''; }, 6000);
await laden();
} catch { /* umgeleitet */ } finally {
knopf.disabled = false;
if ($('melden-hinweis').textContent === 'wird geschickt …') {
$('melden-hinweis').textContent = '';
}
}
});
}
/* ===================================================================
EINE MELDUNG ALS KARTE
=================================================================== */
/** 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');
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);
}
wohin.append(kasten);
}
function karteBauen(m) {
const k = el('article', 's-karte');
k.dataset.stand = m.stand;
k.id = `meldung-${m.id}`;
const kopf = el('div', 's-karte__kopf');
const marke = el('span', 's-marke', STAND_WORT[m.stand] || m.stand);
marke.dataset.stand = m.stand;
kopf.append(marke);
if (leitung && m.von) {
kopf.append(el('span', 's-karte__von', m.von));
}
const zeit = el('span', 's-karte__zeit', herZeit(m.erstellt));
zeit.title = genau(m.erstellt);
kopf.append(zeit);
k.append(kopf);
/* `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));
bilderAnhaengen(k, m.bilder, 'Bild zur Meldung');
if (leitung && m.seite) {
const wo = el('p', 's-karte__wo', 'gemeldet auf ' + m.seite);
if (m.geraet) wo.title = m.geraet;
k.append(wo);
}
/* ==== DER VERLAUF STATT NUR DER LETZTEN ANTWORT (25.09.2026) ==
Filipe: „mit dem gedanken das manchmal sachen mehrmal nicht
sofort perfekt sein werden."
Hier stand nur `m.antwort` -- die LETZTE. Bei Runde drei war
damit nicht mehr zu sehen, was beim ersten Mal versucht wurde,
und genau das loest einen wiederkehrenden Fehler.
DIE RUNDEN STEHEN UNTEREINANDER, jede mit dem, was die Leitung
gesagt hat, und dem, was der Melder daraufhin geantwortet hat.
Gibt es noch keine Runde (die Meldung ist frisch), steht wie
bisher die blosse Antwort da -- Meldungen von vor diesem Umbau
haben keinen Verlauf, und ein leerer Kasten waere schlechter
als der alte Satz. */
const verlauf = Array.isArray(m.verlauf) ? m.verlauf : [];
if (verlauf.length) {
const liste = el('div', 's-verlauf');
for (const r of verlauf) {
const runde = el('div', 's-runde');
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);
}
/* ==== DAS BILD ZUR ANTWORT (02.10.2026) ==================
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.
ANKLICKBAR UND IN EIGENEM FENSTER: dieselbe Behandlung wie
die Bilder der Meldung weiter oben. Ein Bildschirmfoto in
Briefmarkengröße beantwortet keine Frage.
SEIT DEM 02.10. DIESELBE FUNKTION wie oben, und bis zu
drei Bilder. Hier stand ein eigener, fast gleicher Block —
zwei Stellen, an denen dasselbe passiert, sind eine zu
viel. */
bilderAnhaengen(runde, r.bilder, 'Bildschirmfoto zur Antwort in Runde ' + r.nr);
liste.append(runde);
}
k.append(liste);
} else if (m.antwort) {
const ant = el('div', 's-antwort');
ant.append(el('span', 's-antwort__schild', 'Antwort'));
ant.append(el('p', 's-antwort__text', m.antwort));
k.append(ant);
}
/* ==== DER MELDER IST GEFRAGT (25.09.2026) ====================
Filipe: „und erst wenn es funktioniert gedrueckt wird, will ich
dass alles richtig fertig ist."
`darf_bestaetigen` KOMMT VOM SERVER -- er weiss, wessen Meldung
es ist, und diese Datei bekommt jeder, der die Seite oeffnet.
Ein Rollenvergleich im Browser waere die zweite Wahrheit, die in
diesem Haus diese Woche viermal veraltet ist. */
if (m.darf_bestaetigen) k.append(knoepfeFuerMelder(m));
else if (leitung && m.stand !== 'erledigt') k.append(knoepfeFuerLeitung(m));
return k;
}
/** Die zwei Knoepfe des Melders.
*
* „GEHT WIEDER" ZUERST und ohne Rueckfrage: Es ist der haeufige und
* der harmlose Fall -- wer bestaetigt, dass etwas geht, soll dafuer
* kein Fenster wegklicken muessen.
*
* „NOCH NICHT" FRAGT NACH. Ohne ein Wort dazu faengt die Suche von
* vorn an, und die naechste Runde waere dieselbe wie die letzte.
* Der Server verlangt es ohnehin; hier wird es gefragt, statt eine
* Absage zu kassieren.
*/
function knoepfeFuerMelder(m) {
const reihe = el('div', 's-karte__tun');
const geht = el('button', 'schritt s-tun--geht', 'Geht wieder ✓');
geht.type = 'button';
geht.addEventListener('click', () => rueckmelden(m, true, ''));
reihe.append(geht);
const nicht = el('button', 'schritt', 'Noch nicht …');
nicht.type = 'button';
nicht.addEventListener('click', async () => {
const antwort = await window.frageNach?.({
titel: 'Geht es noch nicht?',
was: 'Die Meldung geht zurück an DogFather und die rechte Hand.',
bleibt: 'Alles bisher Geschriebene bleibt stehen – ihr fangt nicht von vorn an.',
ja: 'Zurückgeben',
grund: 'Was geht noch nicht?',
grundPflicht: true,
grundMin: 3,
platzhalter: 'z. B. Der Knopf geht jetzt, aber das Datum stimmt immer noch nicht.',
/* ==== EIN BILD DARF MIT (02.10.2026) ====================
VanVan: „… damit man nochmal ein Bild anhängen kann, wenn
das Problem noch besteht oder sich durch die Änderung ein
neues Problem ergeben hat."
Ihr zweiter Halbsatz ist der Grund: Beim Melden war das
Bild vom ERSTEN Problem. Taucht durch die Änderung ein
neues auf, hilft das alte Bild niemandem.
NIE PFLICHT. Wer keines hat, antwortet ohne -- ein
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.bilder || []);
});
reihe.append(nicht);
return reihe;
}
/** Die Antwort des Melders -- mit Bild oder ohne.
*
* DERSELBE WEG WIE BEIM MELDEN (02.10.2026): Text und Urteil
* reisen im Kopf, das Bild im Rumpf. Ein zweiter Weg nur für den
* Anhang hätte einen Zustand dazwischen -- eine Antwort, die schon
* zählt, während das Bild noch lädt.
*
* 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, 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: kopf,
body: new Blob(anhang),
});
/* DURCH DIESELBE UEBERSETZUNG WIE ALLES ANDERE (25.09.2026,
gefunden von pruef-meldungen). Hier stand `f.fehler` roh --
schickte der Server einmal eine Kennung statt eines Satzes
(`nicht_gefunden`), stuende das mit Unterstrich auf dem
Bildschirm. `fehlerText` gibt es in dieser Datei seit jeher;
die zwei anderen Stellen benutzen es, diese neue nicht.
Genau so entsteht eine Ausnahme: nicht aus Absicht, sondern
weil man die Hausregel beim Neuschreiben nicht danebenliegen
hatte. */
if (!a.ok) {
melde('eingang-fehler', await fehlerText(a, 'Das ging gerade nicht.'));
return;
}
await laden();
} catch { /* hole() hat schon gemeldet */ }
}
/** Die zwei Handgriffe der Leitung.
*
* ZWEI, NICHT FÜNF. „Ich kümmere mich" und „erledigt". Eine
* Zuweisung an eine bestimmte Person, Prioritäten, Kategorien — das
* wäre ein Ticketsystem für zwei Menschen, die nebeneinander
* sitzen. */
function knoepfeFuerLeitung(m) {
const reihe = el('div', 's-karte__tun');
if (m.stand === 'neu') {
const dran = el('button', 'schritt', 'Ich kümmere mich');
dran.type = 'button';
dran.addEventListener('click', () => aendern(m, 'in_arbeit', ''));
reihe.append(dran);
}
/* ==== WER IST DRAN? (25.09.2026) =============================
Liegt die Meldung beim Melder, ist die Leitung NICHT dran --
und ein Knopf „Behoben – nachfragen ..." waere dort schlicht
falsch: Sie hat ja schon nachgefragt. Gefunden hat das kein
Test, sondern ein Bildschirmfoto der fertigen Seite.
DER HINWEIS STEHT VOR DEM KNOPF, nicht statt seiner: Manchmal
faellt einem hinterher noch etwas ein, und dann soll man es
schicken koennen. Der Knopf heisst dann aber, was er tut. */
let hinweis = null;
if (m.stand === 'wartet') {
hinweis = el('p', 's-karte__wartet');
hinweis.append(el('span', 's-karte__wartet-punkt'));
hinweis.append(document.createTextNode(
m.von ? `Liegt bei ${m.von} – sie oder er sagt, ob es wieder geht.`
: 'Liegt beim Melder – er sagt, ob es wieder geht.'));
}
/* DER KNOPF HEISST ANDERS, WEIL ER ETWAS ANDERES TUT (25.09.2026).
„Erledigt" hiess: zu. Jetzt geht die Meldung damit zuerst an den
MELDER, und erst sein Druck schliesst sie. Ein Knopf, der
„Erledigt" sagt und in Wahrheit eine Frage stellt, waere genau
die Sorte Beschriftung, die man einmal glaubt und danach nie
wieder. */
const fertig = el('button', 'schritt',
m.stand === 'wartet' ? 'Noch etwas nachschicken …' : 'Behoben – nachfragen …');
fertig.type = 'button';
fertig.addEventListener('click', async () => {
/* WER ZUMACHT, SAGT WARUM. Der Melder sieht nur diesen Satz —
ohne ihn weiß er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Der Server verlangt ihn ohnehin; hier
wird er gefragt, statt eine Absage zu kassieren. */
const nachtrag = m.stand === 'wartet';
const antwort = await window.frageNach?.({
titel: nachtrag ? 'Noch etwas nachschicken?' : 'Behoben – beim Melder nachfragen?',
was: nachtrag
? 'Dein neuer Satz ERSETZT den alten in dieser Runde – der Melder sieht nur ihn.'
: 'Er bekommt deinen Satz und zwei Knöpfe: „Geht wieder“ oder „Noch nicht“.',
bleibt: nachtrag
? 'Die Meldung bleibt bei ihm liegen. Der Verlauf davor bleibt stehen.'
: 'Erledigt ist sie erst, wenn ER bestätigt. Sagt er „noch nicht“, '
+ 'kommt sie zurück – mit allem, was ihr bisher geschrieben habt.',
ja: nachtrag ? 'Nachschicken' : 'Nachfragen',
grund: 'Was ist daraus geworden?',
grundPflicht: true,
grundMin: 3,
platzhalter: nachtrag
? 'z. B. Behoben, der Knopf geht wieder – und das Datum stimmt jetzt auch.'
: 'z. B. Behoben, der Knopf geht wieder.',
});
if (!antwort) return;
await aendern(m, 'erledigt', antwort.grund || '');
});
reihe.append(fertig);
/* EIN FRAGMENT, WEIL `reihe.before(...)` HIER NICHTS TUT.
Die Reihe haengt in diesem Moment noch an keinem Elternknoten --
sie wird erst vom Aufrufer eingesetzt --, und `before()` auf
einem heimatlosen Element ist wirkungslos. Ohne Fehlermeldung:
Der Hinweis wurde gebaut, stand nirgends, und das Bild sah aus
wie vorher. Aufgefallen beim Nachsehen am Bildschirmfoto, nicht
im Code. */
if (!hinweis) return reihe;
const beides = document.createDocumentFragment();
beides.append(hinweis, reihe);
return beides;
}
async function aendern(m, stand, antwort) {
melde('eingang-fehler', '');
try {
const a = await hole(`/workspace/api/support/${m.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stand, antwort }),
});
if (!a.ok) {
melde('eingang-fehler', await fehlerText(a, 'Das hat nicht geklappt.'));
return;
}
await laden();
} catch { /* umgeleitet */ }
}
/* ===================================================================
ZEICHNEN
=================================================================== */
function bilanzZeichnen() {
const ziel = $('bilanz');
ziel.textContent = '';
const zahl = (stand) => meldungen.filter((m) => m.stand === stand).length;
for (const [stand, wort] of [
/* DER VIERTE STAND BEKOMMT SEINE EIGENE ZAHL (25.09.2026).
Ohne sie zaehlte „wartet" nirgends mit -- die drei Kaesten
oben ergaeben nicht mehr die Gesamtzahl, und Meldungen, die
auf eine Bestaetigung warten, waeren unsichtbar. Genau die
Sorte Zustand, die man beim Zaehlen vergisst. */
['neu', 'neu'], ['in_arbeit', 'in Arbeit'],
['wartet', 'wartet auf Melder'], ['erledigt', 'erledigt'],
]) {
const kasten = el('div', 's-bilanz__feld');
kasten.dataset.stand = stand;
/* OB DAS FELD LEER IST, steht als Merkmal daran -- das Stilblatt
faerbt die "neu"-Null damit nicht mehr rot. Gerechnet wird es
hier und nicht im CSS: Dort gibt es keine Zahlen, nur Text,
und ":empty" traefe eine "0" nicht. */
const n = zahl(stand);
if (!n) kasten.dataset.leer = 'ja';
kasten.append(el('span', 's-bilanz__zahl', String(n)));
kasten.append(el('span', 's-bilanz__wort', wort));
ziel.append(kasten);
}
}
function filterZeichnen() {
const ziel = $('filter');
ziel.textContent = '';
const zaehl = (welche) => welche === 'alle'
? meldungen.length
: welche === 'offen'
? meldungen.filter((m) => m.stand !== 'erledigt').length
: meldungen.filter((m) => m.stand === welche).length;
for (const [wert, wort] of [
['offen', 'Offen'], ['erledigt', 'Erledigt'], ['alle', 'Alle'],
]) {
const k = el('button', 's-filter__knopf');
k.type = 'button';
k.dataset.wert = wert;
k.setAttribute('aria-pressed', String(filter === wert));
k.append(el('span', null, wort));
k.append(el('span', 's-filter__zahl', String(zaehl(wert))));
k.addEventListener('click', () => { filter = wert; listeZeichnen(); });
ziel.append(k);
}
}
function listeZeichnen() {
if (leitung) {
filterZeichnen();
const ziel = $('eingang-liste');
ziel.textContent = '';
const drin = meldungen.filter((m) => filter === 'alle' ? true
: filter === 'offen' ? m.stand !== 'erledigt' : m.stand === filter);
if (!drin.length) {
/* EIN LEERER EINGANG IST EINE GUTE NACHRICHT und soll auch so
aussehen — nicht wie eine Seite, die nicht fertig geladen
hat. */
ziel.append(el('p', 's-leer', filter === 'offen'
? 'Nichts offen. Alles abgearbeitet.'
: 'Hier steht nichts.'));
return;
}
for (const m of drin) ziel.append(karteBauen(m));
return;
}
const block = $('meine-block');
const ziel = $('meine-liste');
ziel.textContent = '';
$('meine-zahl').textContent = String(meldungen.length);
block.hidden = meldungen.length === 0;
for (const m of meldungen) ziel.append(karteBauen(m));
}
async function laden() {
try {
const a = await hole('/workspace/api/support');
if (!a.ok) { melde('eingang-fehler', 'Die Meldungen ließen sich nicht laden.'); return; }
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;
/* DIE SEITE STELLT SICH AUF DEN UM, DER SIE ÖFFNET. */
$('eingang-block').hidden = !leitung;
$('meine-block').hidden = leitung || meldungen.length === 0;
if (leitung) {
$('seiten-titel').textContent = 'Support';
$('seiten-unter').textContent =
'Was aus dem Team und der Community gemeldet wurde. '
+ 'Das Älteste steht oben – was am längsten wartet, gehört nach vorn.';
/* MELDEN KANN DIE LEITUNG AUCH. Filipe findet selbst Fehler,
und ein Weg, der für ihn nicht gilt, ist ein zweiter Weg,
den er dann anders benutzt. Das Feld rutscht nur nach
unten: Wer den Eingang aufmacht, will zuerst sehen, was
liegt. */
/* WIRKLICH VERSCHIEBEN, nicht nur eine Klasse setzen.
`order` wirkt nur in einem Flex- oder Rasterbehaelter, und
`.inhalt` ist keiner -- nachgemessen in start.css. Ein
`append` an denselben Elternteil bewegt den Kasten ans
Ende, ohne ihn neu zu bauen: Der Text im Feld bleibt
stehen, falls gerade jemand tippt. */
const kasten = $('melden-block');
kasten.classList.add('s-melden--unten');
kasten.parentElement?.append(kasten);
bilanzZeichnen();
}
listeZeichnen();
/* AUS EINER BENACHRICHTIGUNG KOMMT MAN MIT ?meldung=… — dann
gehört der Blick genau dorthin und nicht an den Seitenanfang. */
const wohin = new URLSearchParams(location.search).get('meldung');
if (wohin) {
const karte = document.getElementById(`meldung-${Number(wohin)}`);
if (karte) {
karte.scrollIntoView({ behavior: window.sanft?.() || 'auto', block: 'center' });
karte.dataset.hervor = 'ja';
}
}
} catch { /* umgeleitet */ }
}
meldenEinrichten();
laden();
})();