ZWEI SACHEN, EINE DAVON EIN ECHTER BEFUND. 1) DIE KARTEN KAMEN AN DER RECHTETAFEL VORBEI Der Bauplan sagt in Kapitel 04 und noch einmal in der Abnahme: "Leite die sichtbaren Karten aus derselben Rechte-Tafel ab, die rechte.html verwendet. Eine Rolle sieht nur Karten zu Seiten, die sie oeffnen darf; pruefe das SERVERSEITIG." Ich habe dafuer eine Pruefung geschrieben, die die Seite ueber die ECHTE Schnittstelle sperrt (nicht mit einem UPDATE in der Datenbank -- nur so laeuft tafelLaden() mit). Sie wurde sofort rot: Nach dem Sperren von bereich.html fuer einen Modi lieferte der Server weiterhin ALLE ELF Bereichs-Karten. Im Browser fiel das nicht auf, und das ist der heikle Teil: Die Seite verbindet jede Karte mit einer Kachel, und die Kachelliste ist gefiltert -- die Karten verschwanden also auf dem Bildschirm. Die TEXTE gingen trotzdem hinaus. Eine Oberflaeche, die etwas nicht anzeigt, ist keine Schranke; genau deshalb steht im Bauplan "serverseitig". Jetzt siebt `darfKarte()` an der Quelle. Der Pfad wird aus dem Schluessel GERECHNET (`bereich:highlight` -> /workspace/bereich.html), nicht aus einer Zuordnungsliste -- das ist die Umkehrung von `schluesselVonZiel`, und eine gepflegte Tabelle daneben waere die, die beim naechsten neuen Brett fehlt. Entschieden wird mit `darfSeite` selbst; hier wird nichts davon nachgebaut. Gegenprobe in derselben Pruefung: zuruecksperren -> die Karten sind wieder da (29 von 29). Und eine Zeile dazwischen, die zaehlt, dass NUR sie verschwunden sind -- waere mit der Sperre die halbe Anleitung weggefallen, waere die erste Zeile auch gruen gewesen. 2) „AB DABEI" UND „AB STAMM" AN DER KARTE (Kapitel 04) "Kann ein Community-Mitglied etwas wegen seiner Stufe noch nicht, steht das an der Karte." Auch das abgeleitet: aus TREFF_SCHREIBEN, derselben Tafel, die das Schreiben ENTSCHEIDET. `null` darin heisst "von aussen schreibt hier niemand" -- das ist keine Stufe, die man erreicht, und bekommt deshalb keine Marke. "ab Stamm" an einem Anschlagbrett waere ein Versprechen, das nie eingeloest wird. Als Wort, nicht als Schloss-Symbol: Ein Schloss sagt "du darfst nicht" und laesst offen, ob jemals. "ab Dabei" sagt, dass es kommt -- und wann, steht mit Datum in der Karte "Deine Stufe" darueber. Ruhig gestaltet, nicht rot: Es ist keine Sperre, sondern eine Auskunft ueber die Zeit. GEPRUEFT (pruef-anleitung 166 -> 173) Die wichtigste der neuen Zeilen ist die, dass die Marke nach dem Aufstieg VERSCHWINDET. Eine Marke, die bleibt, ist schlimmer als keine: Sie sagt jemandem, er duerfe etwas nicht, das er laengst darf, und er probiert es gar nicht erst. Gemessen an drei Karten mit drei verschiedenen Antworten (Chat: Dabei, Highlights: Stamm, Anschlagbrett: gar nichts) -- waere die Rechnung grob falsch, traefe sie alle drei gleich. Dazu die Gegenprobe, dass eine Teamrolle keine einzige solche Marke bekommt. Mitgelaufen: pruef-rechtetafel 19, pruef-rechte-umstellen 56, pruef-css-klassen, pruef-tippziele 13 -- alle gruen. Die Abnahmezahlen aus Kapitel 13 (14/29/29/34 mit ihrer Aufteilung) sind nach dem neuen Sieb unveraendert; das ist der Beleg, dass es im Normalfall nichts wegnimmt. Im Browser nachgemessen (390 px, echte Anmeldung): Community sieht vier Marken (Rudel-Chat, Wunschliste, Mitmachen „ab Dabei", Highlights „ab Stamm"), die rechte Hand keine einzige. Co-Authored-By: Claude Opus 5 <[email protected]>
730 lines
30 KiB
JavaScript
730 lines
30 KiB
JavaScript
/* =====================================================================
|
||
anleitung.js — die Einweisung je Rolle (Etappe 1)
|
||
|
||
---------------------------------------------------------------------
|
||
DIE KARTEN ENTSTEHEN AUS ZWEI QUELLEN, UND DAS IST DER PUNKT
|
||
|
||
Der Server liefert je Kachel die STUFE und drei Sätze. Name, Symbol,
|
||
Farbe und Adresse kommen aus `window.Bereiche` — derselben Liste, aus
|
||
der die Startseite ihre Kacheln baut.
|
||
|
||
Deshalb sieht eine Karte hier aus wie die echte Kachel, und zwar
|
||
dauerhaft: Wird eine Kachel umbenannt oder bekommt sie eine andere
|
||
Farbe, ändert sich die Karte mit. Stünde der Name in der Datenbank,
|
||
wäre die Anleitung ab diesem Tag eine Beschreibung von etwas, das es
|
||
nicht mehr gibt — und niemandem würde es auffallen.
|
||
|
||
Verbunden wird über `Bereiche.schluesselVonZiel`.
|
||
===================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const BASIS = '/workspace/api/anleitung';
|
||
|
||
/* Der zuletzt geladene Stand. Gebraucht, weil die Pflege nach dem
|
||
Speichern neu zeichnen muss -- und weil der Schalter "bearbeiten"
|
||
dieselben Daten noch einmal braucht, ohne sie erneut zu holen. */
|
||
let daten = null;
|
||
let pflegeAn = false;
|
||
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
const $ = (id) => document.getElementById(id);
|
||
/** „2026-10-13" -> „13.10.2026". Der Server schickt den Tag in der
|
||
* sortierbaren Form; gelesen wird hier die gewohnte. */
|
||
const datumKurz = (iso) => {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
|
||
return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || '');
|
||
};
|
||
|
||
async function hole(pfad, einst = {}) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...einst });
|
||
/* Abgemeldet heißt zurück zur Anmeldung — nicht eine leere Seite
|
||
mit einer Fehlermeldung, die niemand einordnen kann. */
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return a;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER FORTSCHRITT
|
||
|
||
Er kommt vom Server und wird hier NICHT nachgerechnet. Eine Seite,
|
||
die selbst mitzählt, zählt irgendwann anders als der Server — und
|
||
dann stehen zwei Zahlen auf einem Bildschirm, von denen eine falsch
|
||
ist, ohne dass man ihr das ansieht.
|
||
===================================================================== */
|
||
function standSetzen(f) {
|
||
const kasten = $('stand');
|
||
if (!f || !f.gesamt) { kasten.hidden = true; return; }
|
||
kasten.hidden = false;
|
||
const balken = $('stand-balken');
|
||
balken.max = f.gesamt;
|
||
balken.value = f.erledigt;
|
||
const fertig = f.erledigt >= f.gesamt;
|
||
kasten.dataset.fertig = fertig ? 'ja' : 'nein';
|
||
$('stand-satz').textContent = fertig
|
||
? 'Alle Ersten Schritte erledigt. Den Rest holst du dir, wenn du ihn brauchst.'
|
||
: `${f.erledigt} von ${f.gesamt} Schritten erledigt`;
|
||
}
|
||
|
||
/* =====================================================================
|
||
EIN HAKEN
|
||
|
||
Ein echtes `input type="checkbox"`: Tastatur, Vorleseprogramme und
|
||
der Zustand „gedrückt" kommen damit mit. Ein nachgebauter Knopf
|
||
vergisst davon immer mindestens eines.
|
||
|
||
ES WIRD SOFORT GESCHICKT UND NICHT GESAMMELT. Ein „Speichern"-Knopf
|
||
unter fünf Haken wäre die Stelle, an der jemand die Seite verlässt
|
||
und sein Fortschritt weg ist.
|
||
|
||
GEHT ES SCHIEF, GEHT DER HAKEN ZURÜCK. Ein Haken, der steht,
|
||
obwohl der Server ihn nicht hat, ist schlimmer als keiner: Beim
|
||
nächsten Laden ist er weg, und niemand weiß warum.
|
||
===================================================================== */
|
||
function hakenBauen(s, li) {
|
||
const schild = el('label', 'an-haken');
|
||
const feld = document.createElement('input');
|
||
feld.type = 'checkbox';
|
||
feld.className = 'an-haken__feld';
|
||
feld.checked = !!s.erledigt;
|
||
/* Der Name des Schrittes steht im Verweis daneben; das Kästchen
|
||
selbst braucht trotzdem eine eigene Ansage. */
|
||
feld.setAttribute('aria-label', `„${s.titel}“ erledigt`);
|
||
schild.append(feld);
|
||
schild.append(el('span', 'an-haken__zeichen'));
|
||
|
||
feld.addEventListener('change', async () => {
|
||
const willErledigt = feld.checked;
|
||
feld.disabled = true;
|
||
try {
|
||
const a = await hole(`${BASIS}/schritt/${s.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ erledigt: willErledigt }),
|
||
});
|
||
if (!a.ok) throw new Error('abgelehnt');
|
||
const r = await a.json();
|
||
s.erledigt = r.erledigt;
|
||
if (r.erledigt) li.dataset.erledigt = 'ja';
|
||
else li.removeAttribute('data-erledigt');
|
||
standSetzen(r.fortschritt);
|
||
} catch {
|
||
feld.checked = !willErledigt;
|
||
melde('Der Haken ließ sich nicht speichern. Noch einmal versuchen?');
|
||
} finally {
|
||
feld.disabled = false;
|
||
}
|
||
});
|
||
return schild;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE KACHELN DIESER ROLLE — abgeleitet, nicht abgeschrieben
|
||
===================================================================== */
|
||
function kachelnFuer(rolle) {
|
||
const B = window.Bereiche;
|
||
const nach = new Map();
|
||
if (!B) return nach;
|
||
|
||
/* ==== ZWEI HAEUSER, ZWEI QUELLEN (06.10.2026) ==================
|
||
|
||
Bis hierher kamen die Kacheln immer aus `Bereiche.GRUPPEN` --
|
||
der Liste im Browser. Das stimmt fuer die Agentur: Dort schickt
|
||
der Server bewusst `bereiche: null` ("nimm deine eigene
|
||
Liste").
|
||
|
||
Fuer Team Dogi schickt er eine ECHTE Liste, und die Browserliste
|
||
kennt deren Kacheln gar nicht. Gemessen: Die Crew-Anleitung
|
||
zeigte 0 Karten, obwohl die Schnittstelle 14 bzw. 34 lieferte --
|
||
jede Karte wurde weggelassen, weil zu ihrem Schluessel keine
|
||
Kachel zu finden war. Von aussen sah die Seite einfach leer aus.
|
||
|
||
Dieselbe Unterscheidung, die auch die Startseite trifft: Ist es
|
||
ein Feld, gilt es; ist es `null`, nimmt der Browser seine
|
||
eigene. Eine zweite Kachelliste im Browser waere die, die beim
|
||
naechsten Umbau des Rudels veraltet. */
|
||
if (Array.isArray(serverKacheln)) {
|
||
for (const k of serverKacheln) {
|
||
const schluessel = B.schluesselVonZiel(k?.ziel);
|
||
if (schluessel) nach.set(schluessel, k);
|
||
}
|
||
return nach;
|
||
}
|
||
|
||
if (!Array.isArray(B.GRUPPEN)) return nach;
|
||
for (const gruppe of B.GRUPPEN) {
|
||
for (const k of (gruppe.bereiche || [])) {
|
||
if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue;
|
||
const schluessel = B.schluesselVonZiel(k.ziel);
|
||
if (schluessel) nach.set(schluessel, k);
|
||
}
|
||
}
|
||
return nach;
|
||
}
|
||
|
||
/* =====================================================================
|
||
EINE KARTE
|
||
===================================================================== */
|
||
function karteBauen(eintrag, kachel, stufe) {
|
||
const karte = el('article', 'an-karte');
|
||
/* DER SCHLUESSEL BLEIBT AM ELEMENT STEHEN. Er kostet nichts und
|
||
ist der einzige Weg, eine bestimmte Karte spaeter wiederzufinden
|
||
-- „Neu fuer dich" auf der Zentrale springt darueber hierher.
|
||
Eine ID waere dafuer schlechter: Schluessel wie `chat:treff`
|
||
muessten dann in etwas umgerechnet werden, das als ID taugt,
|
||
und diese Umrechnung stuende an zwei Stellen. */
|
||
karte.dataset.kachel = String(eintrag.kachel || '');
|
||
if (kachel.ton !== undefined && kachel.ton !== null) {
|
||
karte.dataset.ton = String(kachel.ton);
|
||
}
|
||
|
||
const kopf = el('div', 'an-karte__kopf');
|
||
const zeichen = el('span', 'an-karte__zeichen');
|
||
zeichen.append(window.Bereiche.zeichenBauen(kachel.zeichen, 'an-karte__svg'));
|
||
kopf.append(zeichen);
|
||
|
||
const namen = el('div', 'an-karte__namen');
|
||
namen.append(el('h3', 'an-karte__name', kachel.name));
|
||
/* DIE STUFE STEHT ALS WORT DA, nicht nur als Farbe. Wer Farben
|
||
schlecht unterscheidet, läse sonst eine Karte ohne Stufe — und
|
||
die Stufe ist das Einzige, was diese Seite überhaupt sortiert. */
|
||
namen.append(el('span', 'an-karte__stufe', stufe.name));
|
||
/* DIE MARKE "NEU" steht als WORT da, nicht als farbiger Punkt.
|
||
Ein Punkt ohne Wort ist eine Frage, keine Auskunft. Sie
|
||
verschwindet von selbst: 14 Tage oder bis die Person die Seite
|
||
nach der Änderung geöffnet hat -- gerechnet wird das im Server. */
|
||
if (eintrag.neu) namen.append(el('span', 'an-karte__neu', 'Neu'));
|
||
/* „AB DABEI" / „AB STAMM" (Bauplan Kapitel 04). Steht nur da,
|
||
wenn es diese Person BETRIFFT -- der Server schickt das Feld
|
||
gar nicht erst, wenn die Stufe reicht oder gar keine im Spiel
|
||
ist. Eine Marke, die bei jedem steht, erklärt nichts.
|
||
|
||
ALS WORT UND NICHT ALS SCHLOSS-SYMBOL: Ein Schloss sagt „du
|
||
darfst nicht" und lässt offen, ob jemals. „ab Dabei" sagt, dass
|
||
es kommt — und wann, steht in der Karte „Deine Stufe" weiter
|
||
oben, mit Datum. */
|
||
if (eintrag.erst_ab) {
|
||
namen.append(el('span', 'an-karte__abstufe', `ab ${eintrag.erst_ab}`));
|
||
}
|
||
kopf.append(namen);
|
||
if (pflegeAn) kopf.append(aendernKnopf(() => karteOeffnen(eintrag, kachel)));
|
||
karte.append(kopf);
|
||
|
||
const zeilen = el('dl', 'an-karte__zeilen');
|
||
const zeile = (schild, text) => {
|
||
if (!text) return;
|
||
zeilen.append(el('dt', 'an-karte__schild', schild));
|
||
zeilen.append(el('dd', 'an-karte__text', text));
|
||
};
|
||
zeile('Wozu', eintrag.wozu);
|
||
zeile('Was du hier tust', eintrag.tust);
|
||
zeile('Merke', eintrag.merke);
|
||
karte.append(zeilen);
|
||
|
||
const weg = el('a', 'an-karte__weg', 'Zur Kachel');
|
||
/* MIT dem Zusatz hinter dem Fragezeichen. `bereich.html?b=live` und
|
||
`bereich.html?b=technik` sind dieselbe Seite und verschiedene
|
||
Kacheln; ohne den Zusatz landeten fünf Karten am selben Ort. */
|
||
weg.href = kachel.ziel;
|
||
karte.append(weg);
|
||
return karte;
|
||
}
|
||
|
||
/* =====================================================================
|
||
EIN STUFENBLOCK — aufklappbar, Muss offen
|
||
===================================================================== */
|
||
function stufeBauen(stufe, eintraege, kacheln) {
|
||
const block = el('section', 'an-stufe');
|
||
block.dataset.stufe = stufe.schluessel;
|
||
|
||
/* <details> und kein nachgebautes Aufklappen: Es bringt Tastatur,
|
||
Vorlesen und die Suche des Browsers mit. Jeder Nachbau vergisst
|
||
davon mindestens eines. */
|
||
const falte = el('details', 'an-stufe__falte');
|
||
falte.open = !!stufe.offen;
|
||
const griff = el('summary', 'an-stufe__griff');
|
||
griff.append(el('span', 'an-stufe__titel', stufe.titel));
|
||
griff.append(el('span', 'an-stufe__zahl', String(eintraege.length)));
|
||
falte.append(griff);
|
||
|
||
if (stufe.unter) falte.append(el('p', 'an-stufe__unter', stufe.unter));
|
||
|
||
const gitter = el('div', 'an-stufe__gitter');
|
||
let gebaut = 0;
|
||
for (const e of eintraege) {
|
||
const kachel = kacheln.get(e.kachel);
|
||
/* EINE KARTE OHNE KACHEL WIRD NICHT GEZEIGT.
|
||
Sie hätte keinen Namen, kein Symbol und keinen Weg — ein
|
||
Kasten, der nichts tut. Dass das überhaupt nicht vorkommen
|
||
darf, prüft pruef-anleitung.mjs in beide Richtungen; hier
|
||
still zu übergehen ist die richtige Vorsicht, nicht die
|
||
Prüfung. */
|
||
if (!kachel) continue;
|
||
gitter.append(karteBauen(e, kachel, stufe));
|
||
gebaut++;
|
||
}
|
||
/* Die Zahl am Griff muss das zeigen, was wirklich dasteht. */
|
||
griff.lastChild.textContent = String(gebaut);
|
||
if (!gebaut) return null;
|
||
|
||
falte.append(gitter);
|
||
block.append(falte);
|
||
return block;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER SPRUNG AUF EINE BESTIMMTE KARTE (06.10.2026)
|
||
|
||
`anleitung.html?karte=<schluessel>` -- benutzt von „Neu fuer dich"
|
||
auf der Zentrale (Bauplan Kapitel 10: „mit Sprung zur passenden
|
||
Karte").
|
||
|
||
ZUERST AUFKLAPPEN, DANN HINSCROLLEN. „Das kommt regelmaessig" und
|
||
„Nur bei Bedarf" sind zugeklappt; eine Karte, die darin liegt, hat
|
||
ohne diesen Schritt die Hoehe null, und man landet an einer ganz
|
||
anderen Stelle der Seite. Gemessen wird das nicht -- es ist der
|
||
Grund, warum die Reihenfolge hier so herum steht.
|
||
|
||
EIN ABSATZ ALS ANKER UND KEIN BLINKEN: Die Karte bekommt kurz
|
||
eine ruhige Umrandung, damit man sieht, welche gemeint war. Nach
|
||
ein paar Sekunden ist sie wieder wie die anderen.
|
||
|
||
GEHT DER SCHLUESSEL INS LEERE (die Karte gibt es fuer diese Rolle
|
||
nicht), passiert NICHTS. Die Seite ist dann einfach die Seite --
|
||
besser als eine Fehlermeldung ueber etwas, das den Leser nichts
|
||
angeht.
|
||
===================================================================== */
|
||
function springeZurKarte() {
|
||
let gesucht = '';
|
||
try { gesucht = new URLSearchParams(location.search).get('karte') || ''; }
|
||
catch { return; }
|
||
if (!gesucht) return;
|
||
const karte = document.querySelector(
|
||
`.an-karte[data-kachel="${CSS.escape(gesucht)}"]`);
|
||
if (!karte) return;
|
||
const falte = karte.closest('details');
|
||
if (falte) falte.open = true;
|
||
karte.dataset.gesucht = 'ja';
|
||
setTimeout(() => { delete karte.dataset.gesucht; }, 6000);
|
||
try {
|
||
karte.scrollIntoView({
|
||
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches
|
||
? 'auto' : 'smooth',
|
||
block: 'center',
|
||
});
|
||
} catch { /* aeltere Browser: dann eben ohne */ }
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER FILTER
|
||
===================================================================== */
|
||
function filterBauen(stufen, auswahl) {
|
||
const leiste = $('filter');
|
||
leiste.replaceChildren();
|
||
const machen = (wert, text) => {
|
||
const knopf = el('button', 'an-filter__knopf', text);
|
||
knopf.type = 'button';
|
||
knopf.dataset.wert = wert;
|
||
knopf.setAttribute('aria-pressed', String(auswahl === wert));
|
||
knopf.addEventListener('click', () => setzen(wert));
|
||
leiste.append(knopf);
|
||
};
|
||
machen('alle', 'Alles');
|
||
for (const s of stufen) machen(s.schluessel, s.name);
|
||
|
||
function setzen(wert) {
|
||
for (const k of leiste.querySelectorAll('.an-filter__knopf')) {
|
||
k.setAttribute('aria-pressed', String(k.dataset.wert === wert));
|
||
}
|
||
for (const b of $('stufen').querySelectorAll('.an-stufe')) {
|
||
const dran = wert === 'alle' || b.dataset.stufe === wert;
|
||
b.hidden = !dran;
|
||
/* EINE GEFILTERTE STUFE WIRD AUFGEKLAPPT. Wer auf „Muss"
|
||
drückt und dann einen zugeklappten Kasten sieht, hat einen
|
||
Knopf gedrückt, der scheinbar nichts getan hat. */
|
||
if (dran && wert !== 'alle') {
|
||
const f = b.querySelector('.an-stufe__falte');
|
||
if (f) f.open = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* =====================================================================
|
||
AUFBAUEN
|
||
===================================================================== */
|
||
function zeigen(d) {
|
||
daten = d;
|
||
$('ohne-fassung').hidden = true;
|
||
$('fassung').hidden = false;
|
||
$('pflege').hidden = !d.darf_pflegen;
|
||
document.body.dataset.pflege = pflegeAn ? 'ja' : 'nein';
|
||
|
||
$('leitgedanke').textContent = d.leitgedanke || '';
|
||
$('leitgedanke').hidden = !d.leitgedanke;
|
||
|
||
$('saetze').replaceChildren(
|
||
...(d.saetze || []).map((t) => el('li', 'an-saetze__zeile', t)));
|
||
|
||
/* ==================================================================
|
||
DIE GANZE ZEILE IST DER KNOPF, NICHT NUR DAS WORT (06.10.2026)
|
||
|
||
Zuerst war nur der Name verlinkt. Gemessen mit
|
||
mess-anleitung.mjs bei 412 px: 20 Pixel hoch — die halbe
|
||
Hausgröße für etwas, das ein Daumen treffen soll, und das
|
||
ausgerechnet auf der Seite, die für Leute gebaut ist, die zum
|
||
ersten Mal hier sind und auf dem Handy sitzen.
|
||
|
||
Jetzt umschließt der Verweis Name UND Text. Das ist auch die
|
||
ehrlichere Form: Der ganze Schritt ist eine Sache, nicht ein
|
||
Stichwort mit Erklärung daneben.
|
||
|
||
Ohne Ziel bleibt es ein `div` — „Installieren“ ist ein Knopf in
|
||
der Kopfleiste, keine Seite. Ein Verweis ohne Ziel wäre genau
|
||
der tote Knopf, den der Rollen-Rundgang sucht.
|
||
|
||
DER HAKEN STEHT DANEBEN UND NICHT DRUMHERUM (Etappe 2). Ein
|
||
Verweis in einem Kontrollkästchen-Label wäre eine Falle: Wer
|
||
auf den Namen tippt, um zur Kachel zu springen, hätte den
|
||
Schritt abgehakt. Zwei Dinge, zwei Flächen.
|
||
================================================================== */
|
||
$('schritte').replaceChildren(
|
||
...(d.schritte || []).map((s) => {
|
||
const li = el('li', 'an-schritt');
|
||
if (s.erledigt) li.dataset.erledigt = 'ja';
|
||
|
||
if (d.darf_abhaken) li.append(hakenBauen(s, li));
|
||
|
||
const rumpf = s.ziel ? el('a', 'an-schritt__rumpf') : el('div', 'an-schritt__rumpf');
|
||
if (s.ziel) rumpf.href = s.ziel;
|
||
rumpf.append(el('span', 'an-schritt__name', s.titel));
|
||
rumpf.append(el('span', 'an-schritt__text', s.text));
|
||
li.append(rumpf);
|
||
if (pflegeAn) li.append(aendernKnopf(() => zeileOeffnen(s, 'schritt')));
|
||
return li;
|
||
}));
|
||
standSetzen(d.fortschritt);
|
||
|
||
const kacheln = kachelnFuer(d.rolle);
|
||
const stufen = $('stufen');
|
||
stufen.replaceChildren();
|
||
for (const s of (d.stufen || [])) {
|
||
const eintraege = (d.karten || []).filter((k) => k.stufe === s.schluessel);
|
||
const block = stufeBauen(s, eintraege, kacheln);
|
||
if (block) stufen.append(block);
|
||
}
|
||
filterBauen(d.stufen || [], 'alle');
|
||
|
||
const liste = (id, blockId, zeilen, bau) => {
|
||
const ziel = $(id);
|
||
ziel.replaceChildren(...zeilen.map(bau));
|
||
$(blockId).hidden = !zeilen.length;
|
||
};
|
||
/* ---- Drei Dinge vorweg (nur die Community) --------------------
|
||
Leere Liste heisst: Diese Rolle bringt keine mit, und der ganze
|
||
Abschnitt bleibt weg. `liste()` macht das von selbst. */
|
||
liste('vorweg', 'vorweg-block', d.vorweg || [], (z) => {
|
||
const li = el('li', 'an-vorweg__zeile');
|
||
li.append(el('strong', 'an-vorweg__was', z.titel));
|
||
li.append(el('span', 'an-vorweg__text', z.text));
|
||
if (pflegeAn) li.append(aendernKnopf(() => zeileOeffnen(z, 'vorweg')));
|
||
return li;
|
||
});
|
||
|
||
/* ---- Deine Stufe (nur die Community) --------------------------
|
||
KEINE EIGENE RECHNUNG IM BROWSER. Welche Stufe jemand hat und ab
|
||
wann mehr geht, kommt fertig vom Server -- dort steht auch die
|
||
Regel, die es entscheidet. Eine zweite Rechnung hier waere die,
|
||
die irgendwann etwas anderes behauptet als das, was die Person
|
||
dann tatsaechlich darf. */
|
||
const st = d.stufe;
|
||
$('stufe-block').hidden = !st;
|
||
if (st) {
|
||
$('stufe-jetzt').textContent = `Du bist gerade: ${st.name}`;
|
||
$('stufe-liste').replaceChildren(...(st.reihen || []).map((r) => {
|
||
const li = el('li', 'an-stufe__zeile');
|
||
if (r.schluessel === st.jetzt) li.dataset.jetzt = 'ja';
|
||
li.append(el('span', 'an-stufe__name', r.name));
|
||
li.append(el('span', 'an-stufe__ab', r.ab));
|
||
li.append(el('span', 'an-stufe__kann', r.kann));
|
||
/* Das Datum steht nur dort, wo es etwas beantwortet: bei einer
|
||
Stufe, die noch kommt. Bei der eigenen und bei schon
|
||
erreichten waere es Vergangenheit. */
|
||
const weiter = (st.jetzt === 'neu' && r.schluessel !== 'neu')
|
||
|| (st.jetzt === 'dabei' && r.schluessel === 'stamm');
|
||
if (weiter && r.datum) {
|
||
li.append(el('span', 'an-stufe__wann', `frühestens ab ${datumKurz(r.datum)}`));
|
||
}
|
||
return li;
|
||
}));
|
||
}
|
||
|
||
liste('nicht', 'nicht-block', d.nicht || [], (z) => {
|
||
const li = el('li', 'an-nicht__zeile');
|
||
li.append(el('span', 'an-nicht__was', z.titel));
|
||
li.append(el('span', 'an-nicht__wer', z.text));
|
||
if (pflegeAn) li.append(aendernKnopf(() => zeileOeffnen(z, 'nicht')));
|
||
return li;
|
||
});
|
||
liste('takt', 'takt-block', d.takt || [], (z) => {
|
||
const teil = document.createDocumentFragment();
|
||
teil.append(el('dt', 'an-takt__wann', z.titel));
|
||
teil.append(el('dd', 'an-takt__was', z.text));
|
||
return teil;
|
||
});
|
||
liste('fragen', 'fragen-block', d.fragen || [], (z) => {
|
||
const falte = el('details', 'an-frage');
|
||
falte.append(el('summary', 'an-frage__frage', z.titel));
|
||
falte.append(el('p', 'an-frage__antwort', z.text));
|
||
if (pflegeAn) falte.append(aendernKnopf(() => zeileOeffnen(z, 'frage'), 'an-frage__aendern'));
|
||
return falte;
|
||
});
|
||
fehlendeZeigen(d);
|
||
|
||
/* GANZ ZUM SCHLUSS, UND NICHT GLEICH NACH DEN KARTEN.
|
||
Gemessen (mess-anleitung-crew, 06.10.2026): Der Sprung stand
|
||
zuerst direkt hinter den Karten -- die Karte war markiert, ihr
|
||
Abschnitt war aufgeklappt, und sie stand trotzdem NICHT im Bild.
|
||
Grund: Darunter werden danach noch „Nicht deins", „Dein
|
||
Rhythmus" und die Fragen eingehaengt. Das sanfte Scrollen zielte
|
||
auf eine Hoehe, die sich waehrenddessen verschob. */
|
||
springeZurKarte();
|
||
}
|
||
|
||
function ohneFassung(d) {
|
||
$('fassung').hidden = true;
|
||
/* Kein Fortschritt ohne Schritte. Er steht in der HTML schon auf
|
||
`hidden`; hier noch einmal, weil diese Seite spaeter (Etappe 4)
|
||
die Rolle wechseln koennen soll, ohne neu zu laden. */
|
||
$('stand').hidden = true;
|
||
const p = $('ohne-fassung');
|
||
p.textContent = d.satz || 'Für diese Rolle gibt es keine Einweisung.';
|
||
p.hidden = false;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE PFLEGE (Etappe 4) — nur für DogFather und Spicy Media
|
||
=====================================================================
|
||
|
||
Bearbeitet wird AN ORT UND STELLE. Eine getrennte
|
||
Verwaltungsansicht hieße, zwischen zwei Fenstern zu vergleichen –
|
||
und genau dabei entstehen Texte, die nicht zueinander passen.
|
||
|
||
Die Rolle wird nicht hier gewählt: Dafür gibt es den
|
||
Sicht-Umschalter in der Kopfleiste.
|
||
===================================================================== */
|
||
|
||
function aendernKnopf(beiKlick, klasse) {
|
||
const k = el('button', 'an-aendern' + (klasse ? ' ' + klasse : ''), '✎');
|
||
k.type = 'button';
|
||
k.title = 'Bearbeiten';
|
||
k.setAttribute('aria-label', 'Bearbeiten');
|
||
k.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); beiKlick(); });
|
||
return k;
|
||
}
|
||
|
||
/* Welche Kacheln dieser Rolle haben noch keine Karte?
|
||
Derselbe Abgleich, den pruef-anleitung in beide Richtungen prüft –
|
||
hier als Angebot, ihn zu schließen. */
|
||
function fehlendeZeigen(d) {
|
||
const kasten = $('fehlend-block');
|
||
if (!d.darf_pflegen || !pflegeAn) { kasten.hidden = true; return; }
|
||
const alle = kachelnFuer(d.rolle);
|
||
alle.delete('anleitung');
|
||
const haben = new Set((d.karten || []).map((k) => k.kachel));
|
||
const fehlt = [...alle.entries()].filter(([s]) => !haben.has(s));
|
||
kasten.hidden = !fehlt.length;
|
||
$('fehlend').replaceChildren(...fehlt.map(([schluessel, kachel]) => {
|
||
const li = el('li', 'an-fehlend__zeile');
|
||
li.append(el('span', 'an-fehlend__name', kachel.name));
|
||
const k = el('button', 'an-fehlend__knopf', 'Eintrag anlegen');
|
||
k.type = 'button';
|
||
k.addEventListener('click', async () => {
|
||
k.disabled = true;
|
||
try {
|
||
const a = await hole(`${BASIS}/karte`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ rolle: d.rolle, kachel: schluessel, stufe: 'bedarf',
|
||
wozu: '', tust: '', merke: '' }),
|
||
});
|
||
if (!a.ok) throw new Error('abgelehnt');
|
||
await laden();
|
||
} catch { melde('Der Eintrag ließ sich nicht anlegen.'); k.disabled = false; }
|
||
});
|
||
li.append(k);
|
||
return li;
|
||
}));
|
||
}
|
||
|
||
/* ---- Das Fenster -------------------------------------------------- */
|
||
let imFenster = null; // { art: 'karte'|'zeile', id, unterart }
|
||
|
||
function feld(id, schildId, schild, wert, sichtbar) {
|
||
$(id).hidden = !sichtbar;
|
||
if (schildId) $(schildId).textContent = schild;
|
||
const eingabe = $(id).querySelector('input, textarea, select');
|
||
if (eingabe) eingabe.value = wert ?? '';
|
||
}
|
||
|
||
function karteOeffnen(eintrag, kachel) {
|
||
imFenster = { art: 'karte', id: eintrag.id };
|
||
$('aendern-titel').textContent = kachel.name;
|
||
$('aendern-unter').textContent = 'Stufe und die drei Zeilen dieser Karte.';
|
||
const wahl = $('e-stufe');
|
||
wahl.replaceChildren(...(daten.stufen || []).map((st) => {
|
||
const o = document.createElement('option');
|
||
o.value = st.schluessel;
|
||
o.textContent = st.name;
|
||
return o;
|
||
}));
|
||
wahl.value = eintrag.stufe;
|
||
$('feld-stufe').hidden = false;
|
||
feld('feld-a', 'schild-a', 'Wozu', eintrag.wozu, true);
|
||
feld('feld-b', 'schild-b', 'Was du hier tust', eintrag.tust, true);
|
||
feld('feld-c', 'schild-c', 'Merke', eintrag.merke, true);
|
||
$('feld-ziel').hidden = true;
|
||
$('aendern-fehler').textContent = '';
|
||
$('aendern').showModal();
|
||
}
|
||
|
||
const ZEILEN_SCHILD = {
|
||
schritt: ['Kachel', 'Was zu tun ist'],
|
||
nicht: ['Was du liegen lassen kannst', 'Wer es erledigt'],
|
||
takt: ['Wann', 'Was du aufmachst'],
|
||
frage: ['Frage', 'Antwort'],
|
||
};
|
||
|
||
function zeileOeffnen(z, art) {
|
||
imFenster = { art: 'zeile', id: z.id, unterart: art };
|
||
const [a, b] = ZEILEN_SCHILD[art] || ['Titel', 'Text'];
|
||
$('aendern-titel').textContent = z.titel || 'Eintrag';
|
||
$('aendern-unter').textContent = '';
|
||
$('feld-stufe').hidden = true;
|
||
feld('feld-a', 'schild-a', a, z.titel, true);
|
||
feld('feld-b', 'schild-b', b, z.text, true);
|
||
$('feld-c').hidden = true;
|
||
/* Ein Ziel gibt es nur beim Schritt -- bei den anderen wäre das
|
||
Feld eine Zusage, die nirgends eingelöst wird. */
|
||
feld('feld-ziel', null, null, z.ziel, art === 'schritt');
|
||
$('aendern-fehler').textContent = '';
|
||
$('aendern').showModal();
|
||
}
|
||
|
||
async function aendernSpeichern(e) {
|
||
e.preventDefault();
|
||
if (!imFenster) return;
|
||
const knopf = $('aendern-speichern');
|
||
knopf.disabled = true;
|
||
try {
|
||
const wohin = imFenster.art === 'karte'
|
||
? `${BASIS}/karte/${imFenster.id}` : `${BASIS}/zeile/${imFenster.id}`;
|
||
const rumpf = imFenster.art === 'karte'
|
||
? { stufe: $('e-stufe').value, wozu: $('e-a').value,
|
||
tust: $('e-b').value, merke: $('e-c').value }
|
||
: { titel: $('e-a').value, text: $('e-b').value,
|
||
ziel: imFenster.unterart === 'schritt' ? $('e-ziel').value : null };
|
||
const a = await hole(wohin, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(rumpf),
|
||
});
|
||
if (!a.ok) throw new Error('abgelehnt');
|
||
$('aendern').close();
|
||
await laden();
|
||
} catch {
|
||
$('aendern-fehler').textContent = 'Das ließ sich nicht speichern.';
|
||
} finally {
|
||
knopf.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---- Wer ist wie weit --------------------------------------------- */
|
||
async function standZeigen() {
|
||
const ziel = $('stand-liste');
|
||
ziel.replaceChildren(el('p', 'leise', 'wird geladen …'));
|
||
$('stand-dialog').showModal();
|
||
try {
|
||
const a = await hole(`${BASIS}/stand`);
|
||
if (!a.ok) throw new Error('abgelehnt');
|
||
const r = await a.json();
|
||
if (!r.leute.length) {
|
||
ziel.replaceChildren(el('p', 'leise', 'Noch niemand im Team.'));
|
||
return;
|
||
}
|
||
ziel.replaceChildren(...r.leute.map((p) => {
|
||
const zeile = el('div', 'an-wer');
|
||
if (p.fertig) zeile.dataset.fertig = 'ja';
|
||
zeile.append(el('span', 'an-wer__name', p.name));
|
||
zeile.append(el('span', 'an-wer__rolle', p.rolle));
|
||
zeile.append(el('span', 'an-wer__stand',
|
||
p.gesamt === 0 ? 'keine Schritte hinterlegt'
|
||
: p.fertig ? 'fertig' : `${p.erledigt} von ${p.gesamt}`));
|
||
/* Das Datum nur, wenn es eines gibt -- „zuletzt: nie" ist eine
|
||
Zeile, die nichts sagt und trotzdem Platz braucht. */
|
||
if (p.zuletzt) {
|
||
zeile.append(el('span', 'an-wer__wann',
|
||
new Date(p.zuletzt).toLocaleDateString('de-DE',
|
||
{ day: '2-digit', month: '2-digit', year: 'numeric' })));
|
||
}
|
||
return zeile;
|
||
}));
|
||
} catch {
|
||
ziel.replaceChildren(el('p', 'leise', 'Der Stand ließ sich nicht laden.'));
|
||
}
|
||
}
|
||
|
||
function melde(grund) {
|
||
$('ladefehler-grund').textContent = grund || '';
|
||
$('ladefehler').hidden = false;
|
||
}
|
||
|
||
/* Die Kacheln dieser Person, wenn der Server sie schickt (Crew).
|
||
`null` heisst: Es gilt die Liste im Browser (Agentur). */
|
||
let serverKacheln = null;
|
||
|
||
async function laden() {
|
||
$('ladefehler').hidden = true;
|
||
/* ERST DIE KACHELN, DANN DIE TEXTE. Andersherum waere die erste
|
||
Zeichnung ohne Kacheln -- und damit ohne eine einzige Karte. */
|
||
try {
|
||
const ich = await hole('/workspace/api/ich');
|
||
if (ich.ok) {
|
||
const j = await ich.json();
|
||
const liste = [...(j.bereiche || []), ...(j.bereiche_zusatz || [])];
|
||
serverKacheln = Array.isArray(j.bereiche) ? liste : null;
|
||
}
|
||
} catch { /* Dann gilt die Browserliste -- wie bisher. */ }
|
||
|
||
let a;
|
||
try { a = await hole(BASIS); } catch { melde('Keine Verbindung.'); return; }
|
||
if (a.status === 404) {
|
||
melde('Diese Seite gibt es auf dieser Adresse nicht.');
|
||
return;
|
||
}
|
||
if (!a.ok) { melde('Der Server hat nicht geantwortet.'); return; }
|
||
const d = await a.json();
|
||
if (!d.hat_fassung) { ohneFassung(d); return; }
|
||
zeigen(d);
|
||
}
|
||
|
||
$('pflege-an').addEventListener('click', () => {
|
||
pflegeAn = !pflegeAn;
|
||
$('pflege-an').setAttribute('aria-pressed', String(pflegeAn));
|
||
$('pflege-satz').textContent = pflegeAn
|
||
? 'Änderungen gelten sofort für alle dieser Rolle.' : '';
|
||
if (daten) zeigen(daten);
|
||
});
|
||
$('pflege-stand').addEventListener('click', () => { standZeigen().catch(() => {}); });
|
||
$('stand-zu').addEventListener('click', () => $('stand-dialog').close());
|
||
$('aendern-form').addEventListener('submit', (e) => { aendernSpeichern(e).catch(() => {}); });
|
||
$('aendern-abbruch').addEventListener('click', () => $('aendern').close());
|
||
$('nochmal').addEventListener('click', () => { laden().catch(() => {}); });
|
||
laden().catch(() => melde('Unerwarteter Fehler.'));
|
||
})();
|