Files
dogfather-universe/workspace/assets/js/support.js
T
DogFatherGitandClaude Opus 5 a1c2d49260 Drei Bilder konnte man schon -- nur sagte es niemand
Filipes Frage: „koennen die leute auch schon mehrere fotos schicken
wenn die im support was melden wollen?"

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

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

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

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

GEAENDERT

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

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

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

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

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

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

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

1000 lines
41 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;
/* 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. */
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) {
/* ==== 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;
return;
}
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;
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
=================================================================== */
/** 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');
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) {
/* ==== 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');
/* 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));
if (r.am) {
const wann = el('span', 's-runde__zeit', herZeit(r.am));
wann.title = genau(r.am);
kopfR.append(wann);
}
runde.append(kopfR);
/* (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);
/* (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');
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;
/* 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;
$('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();
})();