Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.
Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.
WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE
Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus
„<a href="https://… ein kaputtes Attribut
/^["'„»\s]+|["'“«.\s]+$/ einen kaputten Ausdruck
"… nichts „mal " + "eben …" eine kaputte Zeichenkette
DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter.
Ein deutsches Schlusszeichen steht NIE
hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.
Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.
Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.
KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.
DIE WACHE DAZU
pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.
GEPRUEFT
pruef-struktur 59 -> 68 Pruefungen, 0 Fehler
node --check auf allen geaenderten JS-Dateien
nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben
gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
reaktion 421, support 63, content 45, terminregel 35, treff 85
Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
Ausgabe, keine einzige ein Vergleich mit dem Seitentext.
GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.
Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.
Co-Authored-By: Claude Opus 5 <[email protected]>
798 lines
33 KiB
JavaScript
798 lines
33 KiB
JavaScript
/* ===================================================================
|
||
Calls & Meeting-Protokolle (Konzept, Seite 13).
|
||
|
||
Reihenfolge der Gruppen ist bewusst nicht chronologisch, sondern nach
|
||
Dringlichkeit: Zuerst die Gespraeche, deren Protokoll FEHLT. Ein
|
||
vergangener Call ohne Protokoll ist der einzige Zustand, der etwas
|
||
von einem verlangt.
|
||
=================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
/* Wer den festen Call-Raum ändern darf. Dieselbe Zweiteilung wie
|
||
überall sonst: DogFather und Manager entscheiden, alle anderen
|
||
benutzen. Der Server prüft es noch einmal -- das hier ist nur die
|
||
Anzeige. */
|
||
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
|
||
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
|
||
Fehler, den Filipe am 07.09.2026 gefunden hat. */
|
||
const LEITUNG = window.Bereiche.LEITUNG;
|
||
|
||
let ich = null;
|
||
let daten = null;
|
||
let offen = new Set();
|
||
let kiDa = 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 melde = (t) => { $('fehler').textContent = t || ''; };
|
||
|
||
const WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
function zeitpunkt(iso) {
|
||
if (!iso) return '';
|
||
const [d, z] = iso.split('T');
|
||
const [j, m, t] = d.split('-');
|
||
const w = WOCHENTAG[new Date(+j, +m - 1, +t).getDay()];
|
||
return `${w}, ${t}.${m}.${j} · ${z} Uhr`;
|
||
}
|
||
const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '');
|
||
|
||
/* Ein Meeting-Link ist nur dann ein Link, wenn er auch einer ist --
|
||
alles andere bleibt schlichter Text (z. B. "bei mir zu Hause"). */
|
||
function istLink(ort) {
|
||
if (!ort) return false;
|
||
try {
|
||
const u = new URL(ort.trim());
|
||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||
} catch { return false; }
|
||
}
|
||
|
||
/** Ein Knopf, der etwas tut und sich dabei selbst sperrt. Ohne das
|
||
* Sperren löst ein doppelter Klick die Aktion zweimal aus -- beim
|
||
* Löschen scheitert der zweite Versuch dann mit einer Fehlermeldung,
|
||
* obwohl alles geklappt hat. */
|
||
function tuKnopf(text, tu) {
|
||
const b = el('button', 'abmelden', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async (ev) => {
|
||
ev.stopPropagation();
|
||
b.disabled = true;
|
||
melde('');
|
||
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/** Läuft dieses Gespräch gerade noch? Ab dem geplanten Beginn bis eine
|
||
* Stunde nach dem geplanten Ende. Gerechnet wird auf den Textformen
|
||
* ('JJJJ-MM-TTThh:mm'), die der Server ohnehin schickt -- sie
|
||
* sortieren sich richtig und brauchen keine Zeitzone. */
|
||
function laeuftNoch(c) {
|
||
if (!daten?.jetzt || !c.beginn) return false;
|
||
const ende = new Date(c.beginn.replace(' ', 'T') + ':00');
|
||
if (Number.isNaN(ende.getTime())) return false;
|
||
ende.setMinutes(ende.getMinutes() + (c.dauer_min || 30) + 60);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
const endeText = `${ende.getFullYear()}-${p(ende.getMonth() + 1)}-${p(ende.getDate())}`
|
||
+ `T${p(ende.getHours())}:${p(ende.getMinutes())}`;
|
||
return c.beginn <= daten.jetzt && daten.jetzt <= endeText;
|
||
}
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return a;
|
||
}
|
||
|
||
/* ---------- Protokollbogen ---------------------------------------------- */
|
||
|
||
function bogen(c) {
|
||
const f = el('form', 'bogen');
|
||
|
||
const feld = (schild, hinweis, wert) => {
|
||
f.append(el('label', 'feld-schild', schild));
|
||
if (hinweis) f.append(el('p', 'bogen__hinweis', hinweis));
|
||
const t = el('textarea', 'bogen__feld');
|
||
t.rows = 3;
|
||
t.maxLength = 6000;
|
||
t.value = wert || '';
|
||
f.append(t);
|
||
return t;
|
||
};
|
||
|
||
const fPunkte = feld('Besprochene Punkte', null, c.punkte);
|
||
const fEnt = feld('Entscheidungen',
|
||
'Was wurde festgelegt? Nicht was diskutiert wurde – was gilt.', c.entscheidungen);
|
||
|
||
/* To-dos: der Teil, der laut Konzept direkt ins Board wandert. */
|
||
f.append(el('label', 'feld-schild', 'To-dos'));
|
||
f.append(el('p', 'bogen__hinweis',
|
||
'Jede Zeile wird sofort eine echte Aufgabe im Brett – mit dem Gespräch als Herkunft.'));
|
||
const todoBox = el('div', 'todos');
|
||
f.append(todoBox);
|
||
|
||
const todoZeile = (fokus) => {
|
||
const z = el('div', 'todo');
|
||
const t = el('input', 'todo__titel');
|
||
t.type = 'text'; t.maxLength = 160;
|
||
t.placeholder = 'Was ist zu tun?';
|
||
const d = el('input', 'todo__frist');
|
||
d.type = 'date'; d.title = 'Frist (optional)';
|
||
const weg = el('button', 'todo__weg', '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Zeile entfernen';
|
||
weg.setAttribute('aria-label', 'Zeile entfernen');
|
||
weg.addEventListener('click', () => {
|
||
z.remove();
|
||
if (!todoBox.children.length) todoZeile(true);
|
||
});
|
||
/* Enter in der letzten Zeile legt die naechste an -- so kann man
|
||
eine Liste tippen, ohne zur Maus zu greifen. */
|
||
t.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Enter') return;
|
||
e.preventDefault();
|
||
if (z === todoBox.lastElementChild && t.value.trim()) todoZeile(true);
|
||
else z.nextElementSibling?.querySelector('.todo__titel')?.focus();
|
||
});
|
||
z.append(t, d, weg);
|
||
todoBox.append(z);
|
||
if (fokus) t.focus();
|
||
return z;
|
||
};
|
||
todoZeile(false);
|
||
|
||
const mehr = el('button', 'knopf-still', '+ Zeile');
|
||
mehr.type = 'button';
|
||
mehr.addEventListener('click', () => todoZeile(true));
|
||
|
||
const reihe = el('div', 'todo-knoepfe');
|
||
reihe.append(mehr);
|
||
|
||
/* Vorschlaege aus dem, was oben schon steht. Der Knopf erscheint
|
||
nur, wenn die KI ueberhaupt laeuft -- sonst gaebe es einen Knopf,
|
||
der immer scheitert. */
|
||
if (kiDa) {
|
||
reihe.append(window.KI.knopf('To-dos vorschlagen', async () => {
|
||
const text = [fPunkte.value, fEnt.value].filter((x) => x.trim()).join(' ');
|
||
if (text.trim().length < 20) {
|
||
throw new Error('Schreib erst ein paar Sätze oben – daraus kann die KI dann ableiten.');
|
||
}
|
||
const d = await window.KI.frage('todos', { text });
|
||
if (!d.todos?.length) throw new Error('Die KI hat nichts gefunden.');
|
||
/* Leere Zeilen zuerst fuellen, dann neue anhaengen. Bereits
|
||
Getipptes wird nie ueberschrieben. */
|
||
for (const vorschlag of d.todos) {
|
||
let ziel = [...todoBox.querySelectorAll('.todo__titel')].find((i) => !i.value.trim());
|
||
if (!ziel) { todoZeile(false); ziel = todoBox.lastElementChild.querySelector('.todo__titel'); }
|
||
ziel.value = vorschlag;
|
||
ziel.dataset.vonKi = 'ja';
|
||
}
|
||
meldung.textContent = '';
|
||
const hinweis = el('p', 'ki-hinweis', d.hinweis);
|
||
reihe.after(hinweis);
|
||
setTimeout(() => hinweis.remove(), 9000);
|
||
}));
|
||
}
|
||
f.append(reihe);
|
||
|
||
/* Nächster Termin */
|
||
f.append(el('label', 'feld-schild', 'Nächster Termin'));
|
||
f.append(el('p', 'bogen__hinweis',
|
||
'Wenn hier etwas steht, wird der Folgetermin gleich in den Kalender gelegt – '
|
||
+ 'mit demselben Gegenüber und demselben Link.'));
|
||
const naechster = el('input', 'bogen__zeit');
|
||
naechster.type = 'datetime-local';
|
||
f.append(naechster);
|
||
|
||
const meldung = el('p', 'fehler');
|
||
meldung.setAttribute('role', 'alert');
|
||
f.append(meldung);
|
||
|
||
const knöpfe = el('div', 'kk__knoepfe');
|
||
const speichern = el('button', 'knopf knopf--klein', 'Protokoll festhalten');
|
||
speichern.type = 'submit';
|
||
const abbrechen = el('button', 'knopf-still', 'Abbrechen');
|
||
abbrechen.type = 'button';
|
||
abbrechen.addEventListener('click', () => { offen.delete(c.id); zeichne(); });
|
||
knöpfe.append(speichern, abbrechen);
|
||
f.append(knöpfe);
|
||
|
||
f.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
meldung.textContent = '';
|
||
const todos = [...todoBox.querySelectorAll('.todo')]
|
||
.map((z) => ({
|
||
titel: z.querySelector('.todo__titel').value,
|
||
frist: z.querySelector('.todo__frist').value,
|
||
}))
|
||
.filter((t) => t.titel.trim());
|
||
|
||
if (!fPunkte.value.trim() && !fEnt.value.trim() && !todos.length) {
|
||
meldung.textContent = 'Ein leeres Protokoll hilft niemandem.';
|
||
return;
|
||
}
|
||
speichern.disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/calls/${c.id}/protokoll`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
punkte: fPunkte.value, entscheidungen: fEnt.value, todos,
|
||
naechster_beginn: naechster.value,
|
||
}),
|
||
});
|
||
if (!a.ok) {
|
||
meldung.textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Ging nicht.');
|
||
return;
|
||
}
|
||
const r = await a.json();
|
||
offen.delete(c.id);
|
||
await laden();
|
||
melde(r.aufgaben
|
||
? `Protokoll festgehalten – ${r.aufgaben} To-do(s) liegen jetzt im Aufgabenbrett.`
|
||
: 'Protokoll festgehalten.');
|
||
setTimeout(() => melde(''), 5000);
|
||
} catch { /* umgeleitet */ } finally { speichern.disabled = false; }
|
||
});
|
||
|
||
return f;
|
||
}
|
||
|
||
/* ---------- Karte -------------------------------------------------------- */
|
||
|
||
function karte(c, gruppe) {
|
||
const k = el('article', 'call');
|
||
k.dataset.gruppe = gruppe;
|
||
k.id = 'call-' + c.id;
|
||
|
||
const kopf = el('div', 'call__kopf');
|
||
const links = el('div');
|
||
links.append(el('h3', 'call__titel', c.titel));
|
||
const wer = [c.creator_name, c.teilnehmer_name].filter(Boolean).join(' · ');
|
||
links.append(el('p', 'call__zeit',
|
||
zeitpunkt(c.beginn) + ` · ${c.dauer_min} Min` + (wer ? ' · ' + wer : '')));
|
||
kopf.append(links);
|
||
|
||
const marken = el('div', 'kk__marken');
|
||
if (c.art === 'review') marken.append(el('span', 'marke-still', 'Review'));
|
||
if (gruppe === 'offen') marken.append(el('span', 'marke-fehlt', 'Protokoll fehlt'));
|
||
if (c.aufgaben.length) {
|
||
const erledigt = c.aufgaben.filter((a) => a.status === 'erledigt').length;
|
||
marken.append(el('span', 'marke-still',
|
||
`${erledigt}/${c.aufgaben.length} To-dos erledigt`));
|
||
}
|
||
kopf.append(marken);
|
||
k.append(kopf);
|
||
|
||
if (c.beschreibung) k.append(el('p', 'call__text', c.beschreibung));
|
||
|
||
/* WO FINDET DER CALL STATT? (02.09.2026)
|
||
|
||
Vorher gab es hier nur dann einen Knopf, wenn jemand von Hand
|
||
einen Link in das Ortsfeld getippt hatte -- und nur, solange das
|
||
Gespräch noch bevorstand. Stand dort "Hier" oder "Discord", war
|
||
nichts zum Anklicken da, und wer beitreten wollte, musste selbst
|
||
wissen wohin. Genau das war die Frage: "wo führe ich den Call?"
|
||
|
||
Jetzt gilt: der eigene Ort des Termins, wenn er ein Link ist --
|
||
sonst der feste Call-Raum des Teams. Und der Knopf bleibt, solange
|
||
das Gespräch laufen kann, nicht nur bis zur Startzeit: Wer sich
|
||
verspätet, braucht ihn genau dann. */
|
||
const knöpfe = el('div', 'kk__knoepfe');
|
||
const ziel = istLink(c.ort) ? c.ort : (daten.call_link || '');
|
||
if (ziel && (gruppe === 'anstehend' || laeuftNoch(c))) {
|
||
const a = el('a', 'knopf knopf--klein', laeuftNoch(c) ? 'Call beitreten' : 'Call öffnen');
|
||
a.href = ziel;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
/* Steht der Raum nicht am Termin, sondern kommt aus den
|
||
Einstellungen, gehört das dazu -- sonst wundert man sich, wohin
|
||
der Knopf führt. */
|
||
if (!istLink(c.ort)) a.title = 'Fester Call-Raum des Teams';
|
||
knöpfe.append(a);
|
||
}
|
||
if (c.ort && !istLink(c.ort)) k.append(el('p', 'call__ort', c.ort));
|
||
|
||
if (gruppe !== 'anstehend') {
|
||
const b = el('button', gruppe === 'offen' ? 'knopf knopf--klein' : 'knopf-still',
|
||
offen.has(c.id) ? 'Schließen'
|
||
: c.protokoll_id ? 'Protokoll ansehen' : 'Protokoll schreiben');
|
||
b.type = 'button';
|
||
b.addEventListener('click', () => {
|
||
/* IMMER NUR EINS OFFEN (Wunsch 02.09.2026: "es soll immer
|
||
automatisch nur 1 angezeigt werden").
|
||
|
||
Vorher konnte man beliebig viele Protokolle gleichzeitig
|
||
aufklappen -- die Seite wurde dabei so lang, dass die Liste
|
||
darunter aus dem Blick geriet und man nicht mehr sah, was
|
||
sonst noch offen ist. Ein neu geöffnetes Gespräch schliesst
|
||
jetzt das vorherige.
|
||
|
||
`clear()` VOR dem Merken: Sonst löschte es den gerade
|
||
gesetzten Eintrag gleich wieder mit. */
|
||
const warOffen = offen.has(c.id);
|
||
offen.clear();
|
||
if (!warOffen) offen.add(c.id);
|
||
zeichne();
|
||
});
|
||
knöpfe.append(b);
|
||
}
|
||
if (knöpfe.childNodes.length) k.append(knöpfe);
|
||
|
||
if (!offen.has(c.id)) return k;
|
||
|
||
const auf = el('div', 'call__detail');
|
||
if (c.protokoll_id) {
|
||
/* Fertiges Protokoll: lesen, nicht bearbeiten. Ein festgehaltenes
|
||
Gespräch nachträglich umzuschreiben wäre genau das, was ein
|
||
Protokoll wertlos macht. */
|
||
if (c.punkte) {
|
||
auf.append(el('p', 'kk__schild', 'Besprochene Punkte'));
|
||
auf.append(el('p', 'call__abschnitt', c.punkte));
|
||
}
|
||
if (c.entscheidungen) {
|
||
auf.append(el('p', 'kk__schild', 'Entscheidungen'));
|
||
auf.append(el('p', 'call__abschnitt', c.entscheidungen));
|
||
}
|
||
if (c.aufgaben.length) {
|
||
auf.append(el('p', 'kk__schild', 'Daraus entstandene Aufgaben'));
|
||
const ul = el('ul', 'todo-liste');
|
||
for (const a of c.aufgaben) {
|
||
const li = el('li');
|
||
li.dataset.status = a.status;
|
||
li.append(el('span', 'todo-liste__titel', a.titel));
|
||
const zus = [a.verantwortlich_name, a.frist ? 'bis ' + datum(a.frist) : null,
|
||
a.status === 'erledigt' ? 'erledigt' : null].filter(Boolean).join(' · ');
|
||
if (zus) li.append(el('span', 'kk__still', zus));
|
||
ul.append(li);
|
||
}
|
||
auf.append(ul);
|
||
}
|
||
auf.append(el('p', 'call__quelle',
|
||
`Festgehalten von ${c.protokoll_von || '—'} am ${datum(c.protokoll_erstellt)}`));
|
||
|
||
/* Löschen darf NUR DogFather (Wunsch 02.09.2026: "nur ich kann die
|
||
löschen sonst keiner"). Der Knopf steht deshalb auch nur bei ihm
|
||
da -- ein Knopf, der bei anderen erscheint und dann abgewiesen
|
||
wird, ist eine Einladung zum Ärgernis. Der Server prüft es
|
||
trotzdem noch einmal; die Anzeige ist keine Sicherung.
|
||
|
||
Gelöscht wird NUR das Protokoll. Das Gespräch bleibt im
|
||
Kalender stehen und rutscht wieder zu "Protokoll fehlt" -- so
|
||
ist die Wirkung umkehrbar: neu schreiben, fertig. */
|
||
if (ich?.rolle === 'admin') {
|
||
const weg = el('div', 'kk__knoepfe');
|
||
weg.append(tuKnopf('Protokoll löschen', async () => {
|
||
if (!await frageNach({
|
||
titel: 'Protokoll zu „' + c.titel + '“ löschen?',
|
||
was: 'Der Text ist danach weg und lässt sich nicht wiederholen.',
|
||
bleibt: 'Das Gespräch bleibt im Kalender und steht danach wieder '
|
||
+ 'unter „Protokoll fehlt“. Daraus entstandene Aufgaben bleiben auch.',
|
||
endgueltig: true, ja: 'Protokoll löschen',
|
||
})) return;
|
||
const a = await hole('/workspace/api/calls/' + c.id + '/protokoll', { method: 'DELETE' });
|
||
if (!a.ok) {
|
||
melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Löschen ging nicht.'));
|
||
return;
|
||
}
|
||
offen.delete(c.id);
|
||
await laden();
|
||
}));
|
||
auf.append(weg);
|
||
}
|
||
if (c.naechster_termin_id) {
|
||
const p = el('p', 'call__quelle', 'Folgetermin liegt im Kalender. ');
|
||
const a = el('a', 'call__verweis', 'Kalender öffnen');
|
||
a.href = 'kalender.html';
|
||
p.append(a);
|
||
auf.append(p);
|
||
}
|
||
} else {
|
||
auf.append(bogen(c));
|
||
}
|
||
k.append(auf);
|
||
return k;
|
||
}
|
||
|
||
/* ---------- Zeichnen ----------------------------------------------------- */
|
||
|
||
const GRUPPEN = [
|
||
{ schluessel: 'offen', titel: 'Protokoll fehlt',
|
||
frage: 'Diese Gespräche sind vorbei, aber noch nicht festgehalten.' },
|
||
{ schluessel: 'anstehend', titel: 'Steht an', frage: null },
|
||
/* "Wiederholungen" und nicht "Laeuft automatisch": Der Kopf ist ein
|
||
REITER, und ein Reiter braucht ein Wort. Bei vier Tafeln in einer
|
||
Reihe bleiben je 252 Pixel -- gemessen, und "LAEUFT AUTOMATISCH"
|
||
passte dort nicht hinein, es stand "LAEUFT AUTO...". Ein
|
||
abgeschnittener Name auf einem Reiter ist das Gegenteil von
|
||
uebersichtlich: Man muss aufklappen, um zu wissen, was drin ist.
|
||
Was es genau bedeutet, steht im Satz darunter -- und den liest
|
||
man ohnehin erst, wenn die Tafel offen ist. */
|
||
{ schluessel: 'serien', titel: 'Wiederholungen',
|
||
frage: 'Läuft automatisch – die Wiederholungen dieses Monats. '
|
||
+ 'Was danach kommt, steht im Kalender.' },
|
||
{ schluessel: 'erledigt', titel: 'Festgehalten', frage: null },
|
||
];
|
||
|
||
/* ---------- Kategorien innerhalb einer Gruppe ---------------------------
|
||
|
||
Wunsch Filipe, 06.09.2026, mit einem Bild von "STEHT AN 27":
|
||
*"die liste da soll kategorisiert sein und werden mit einem button
|
||
zum auf und zu machen."*
|
||
|
||
WARUM NACH ZEIT und nicht nach Titel: Auf dem Bild standen
|
||
siebenundzwanzig Karten am Stück, fast alle "Community-Talk" --
|
||
wöchentliche Serien. Nach Titel gruppiert hätte man zwei
|
||
Überschriften und darunter wieder dieselbe lange Liste. Die Frage,
|
||
die man an eine Terminliste hat, ist "was kommt als Nächstes", und
|
||
die Antwort darauf ist ein Zeitraum.
|
||
|
||
WAS OFFEN IST: nur die erste Kategorie, die etwas enthält. Alles
|
||
aufzuklappen wäre wieder die Wand von vorher; alles zuzuklappen
|
||
hiesse, dass man nach dem Laden erst einmal klicken muss, um
|
||
überhaupt etwas zu sehen. */
|
||
const ZEITRAEUME = [
|
||
{ schluessel: 'heute', titel: 'Heute' },
|
||
{ schluessel: 'woche', titel: 'Diese Woche' },
|
||
{ schluessel: 'naechste', titel: 'Nächste Woche' },
|
||
{ schluessel: 'spaeter', titel: 'Später' },
|
||
];
|
||
|
||
/** In welchen Zeitraum fällt dieser Termin?
|
||
*
|
||
* ORTSZEIT durchgehend. Ein `toISOString()` läge zwischen
|
||
* Mitternacht und zwei Uhr einen Tag daneben -- und ausgerechnet
|
||
* dann wäre "Heute" leer, obwohl etwas ansteht. */
|
||
function zeitraumVon(beginn) {
|
||
const d = new Date(String(beginn).replace(' ', 'T'));
|
||
if (Number.isNaN(d.getTime())) return 'spaeter';
|
||
|
||
const heute = new Date();
|
||
heute.setHours(0, 0, 0, 0);
|
||
const tag = new Date(d); tag.setHours(0, 0, 0, 0);
|
||
const tage = Math.round((tag - heute) / 86400000);
|
||
|
||
if (tage <= 0) return 'heute';
|
||
/* Bis zum Ende DIESER Woche (Sonntag), nicht "in sieben Tagen".
|
||
"Diese Woche" heisst umgangssprachlich bis Sonntag -- wer am
|
||
Freitag "diese Woche" liest, erwartet nicht den Mittwoch darauf. */
|
||
const wochentag = (heute.getDay() + 6) % 7; // Mo=0 … So=6
|
||
const bisSonntag = 6 - wochentag;
|
||
if (tage <= bisSonntag) return 'woche';
|
||
if (tage <= bisSonntag + 7) return 'naechste';
|
||
return 'spaeter';
|
||
}
|
||
|
||
function zeichne() {
|
||
const ziel = $('listen');
|
||
ziel.textContent = '';
|
||
ziel.setAttribute('aria-busy', 'false');
|
||
|
||
/* GANZ LEER? DANN TROTZDEM DIE VIER REITER (08.09.2026).
|
||
|
||
Hier stand ein `return`: Sind alle vier Gruppen leer, wurde NUR
|
||
ein Satz gezeigt und die Seite bestand aus einer Zeile. Wer noch
|
||
keine Calls hat -- ein neuer Scout zum Beispiel -- sah damit eine
|
||
voellig andere Seite als alle anderen.
|
||
|
||
Der Hinweis bleibt, er ist nuetzlich. Er steht jetzt ZUSAETZLICH
|
||
ueber den vier gedaempften Reitern statt an ihrer Stelle: Man
|
||
sieht auf einen Blick, welche vier Faecher es gibt, dass alle
|
||
leer sind, und was zu tun ist, damit sich das aendert. */
|
||
if (!daten.offen.length && !daten.anstehend.length
|
||
&& !(daten.serien || []).length && !daten.erledigt.length) {
|
||
const leer = el('div', 'leer-hinweis');
|
||
leer.append(document.createTextNode('Noch keine Calls. Termine mit der Art „Call“ oder „Review“ '));
|
||
const a = el('a', 'call__verweis', 'im Kalender');
|
||
a.href = 'kalender.html';
|
||
leer.append(a, document.createTextNode(' erscheinen automatisch hier.'));
|
||
ziel.append(leer);
|
||
}
|
||
|
||
/* ===================================================================
|
||
EIN BRETT AUS ZWEI SPALTEN, KEINE LISTE (07.09.2026)
|
||
|
||
Filipe: "du hast gesagt hast die seite umgeaendert hast du aber
|
||
nicht, immer noch gleich aufgestellt, man kann sogar bestimmt zwei
|
||
kategorien neben einander legen."
|
||
|
||
Er hatte recht -- ich hatte beim letzten Mal nur das MATERIAL der
|
||
Karten getauscht und die Aufstellung gelassen. Die drei Gruppen
|
||
standen weiter untereinander, und weil "Steht an" siebenundzwanzig
|
||
Eintraege hat, lagen die beiden anderen ausserhalb des Bildes.
|
||
|
||
Jetzt ist es ein Brett: Die Gruppen sind eigenstaendige Tafeln in
|
||
einem Raster mit zwei Spalten. "Protokoll fehlt" (kurz, dringend)
|
||
und "Festgehalten" (Archiv) landen dadurch NEBEN der langen
|
||
Liste statt darunter -- man sieht alle drei auf einmal.
|
||
|
||
Die Zuordnung macht das CSS ueber die Spaltenbreite, nicht dieses
|
||
Skript. Eine hier festgeschriebene Spalte waere eine Zahl, die
|
||
beim naechsten Fenster falsch ist. */
|
||
ziel.className = 'call-brett';
|
||
|
||
/* AUCH BEIM LADEN IST NUR EINE OFFEN (07.09.2026).
|
||
|
||
Das Akkordeon griff bisher nur beim KLICK. Beim ersten Aufbau
|
||
kamen die gemerkten Staende aus der Ablage, und die konnten drei
|
||
offene Tafeln ergeben -- im Bildschirmfoto standen genau so drei
|
||
nebeneinander offen. Ein Versprechen, das erst nach dem ersten
|
||
Klick gilt, ist keines.
|
||
|
||
Offen bleibt die ERSTE Tafel, die etwas enthaelt und nicht
|
||
ausdruecklich zugeklappt wurde. "Die erste mit Inhalt" ist
|
||
dieselbe Regel, nach der auch die Zeitbloecke aufgehen -- alles
|
||
zuzuklappen hiesse, dass man nach dem Laden erst klicken muss,
|
||
um ueberhaupt etwas zu sehen. */
|
||
let schonOffen = false;
|
||
|
||
for (const g of GRUPPEN) {
|
||
/* `|| []` fuer den Fall, dass ein Server ohne die neue Gruppe
|
||
antwortet -- etwa wenn die Seite noch aus dem Zwischenspeicher
|
||
kommt, der Dienst aber schon neu ist. Eine Oberflaeche, die
|
||
daran abstuerzt, waere der schlechtere Ausgang. */
|
||
const liste = daten[g.schluessel] || [];
|
||
|
||
/* ALLE VIER REITER STEHEN IMMER (08.09.2026).
|
||
|
||
Filipe, mit zwei Bildschirmfotos nebeneinander: "wieso bei den
|
||
scouts so und bei den manager so?" Bei Patrick stand EINE Tafel
|
||
ueber die volle Breite, bei Schulle vier nebeneinander.
|
||
|
||
Hier stand `if (!liste.length) continue;` -- eine leere Gruppe
|
||
wurde gar nicht erst gebaut. Wer nichts Offenes hat, sah
|
||
deshalb eine voellig andere Seite als jemand mit Eintraegen,
|
||
und zwar nicht "weniger", sondern anders AUFGETEILT: Bei einer
|
||
Tafel zieht sich diese ueber die ganze Reihe.
|
||
|
||
Derselbe Fehler wie bei den Aufgabenzahlen auf der Startseite,
|
||
nur an anderer Stelle -- und dieselbe Loesung: Die Tafel bleibt
|
||
stehen, wird GEDAEMPFT (siehe `[data-leer]` in calls.css) und
|
||
zeigt eine Null. "Protokoll fehlt: 0" ist eine Aussage, ein
|
||
fehlender Reiter ist keine.
|
||
|
||
Zugeklappt, wenn leer: Aufklappen wuerde nur eine leere Liste
|
||
zeigen. */
|
||
const leer = !liste.length;
|
||
const s = el('section', 'gruppe');
|
||
s.dataset.gruppe = g.schluessel;
|
||
if (leer) s.dataset.leer = 'ja';
|
||
|
||
/* JEDE TAFEL LAESST SICH ZUKLAPPEN, und der Zustand wird gemerkt
|
||
("so wie es einem lieb ist gerade"). Der Kopf ist der Schalter,
|
||
nicht ein Dreieck daneben -- die ganze Zeile ist ein Ziel von
|
||
40 Pixeln statt eines von vierzehn.
|
||
|
||
Vorgabe: "Festgehalten" ist zu. Es ist das Archiv; wer die Seite
|
||
oeffnet, will wissen, was ANSTEHT. Die anderen beiden stehen
|
||
offen. Wer es anders will, klickt einmal -- und es bleibt so. */
|
||
const kopf = el('button', 'stufe__kopf');
|
||
kopf.type = 'button';
|
||
kopf.append(el('h2', 'stufe__titel', g.titel), el('span', 'stufe__zahl', String(liste.length)));
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('class', 'gruppe__pfeil');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
pfeil.innerHTML = '<path d="m6 9.5 6 6 6-6" fill="none" stroke="currentColor"'
|
||
+ ' stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||
kopf.append(pfeil);
|
||
s.append(kopf);
|
||
|
||
const koerper = el('div', 'gruppe__koerper');
|
||
if (g.frage) koerper.append(el('p', 'block__frage', g.frage));
|
||
|
||
const schluessel = `calls:${g.schluessel}`;
|
||
let zu = leer || g.schluessel === 'erledigt';
|
||
try {
|
||
const gemerkt = localStorage.getItem(schluessel);
|
||
if (gemerkt) zu = gemerkt === 'zu';
|
||
} catch { /* ohne Ablage bleibt die Vorgabe */ }
|
||
/* Die zweite und jede weitere offene Tafel wird zugeklappt --
|
||
ohne den gemerkten Stand zu ueberschreiben: Wer sie aufklappt,
|
||
soll beim naechsten Mal wieder DIESE bekommen und nicht die
|
||
erste. */
|
||
if (!zu && !liste.length) zu = true;
|
||
if (!zu && schonOffen) zu = true;
|
||
if (!zu) schonOffen = true;
|
||
const stand = (istZu) => {
|
||
s.dataset.zu = istZu ? 'ja' : 'nein';
|
||
kopf.setAttribute('aria-expanded', istZu ? 'false' : 'true');
|
||
koerper.hidden = istZu;
|
||
};
|
||
kopf.addEventListener('click', () => {
|
||
zu = !zu; stand(zu);
|
||
try { localStorage.setItem(schluessel, zu ? 'zu' : 'auf'); } catch { /* egal */ }
|
||
|
||
/* IMMER NUR EINE OFFEN (07.09.2026).
|
||
|
||
Filipe: "wenn ich eine aufklicke soll auch immer nur die
|
||
aufgehen und nicht alle 3."
|
||
|
||
Das ist mehr als Geschmack: Seit die drei Tafeln nebeneinander
|
||
stehen und jede ihren eigenen Lauf hat, teilen sie sich die
|
||
Bildschirmhoehe. Drei offene Tafeln heissen drei kurze
|
||
Ausschnitte -- eine offene heisst eine, in der man wirklich
|
||
arbeiten kann.
|
||
|
||
Die anderen werden hier ZU gemacht, nicht versteckt: Ihre
|
||
Koepfe bleiben stehen, mit Namen und Anzahl. Man sieht also
|
||
weiterhin, was es sonst noch gibt, und kommt mit einem Klick
|
||
hin.
|
||
|
||
Der gemerkte Stand wird mitgeschrieben -- sonst waere die
|
||
Seite beim naechsten Aufruf wieder in einem Zustand, den
|
||
niemand hergestellt hat. */
|
||
if (!zu) {
|
||
for (const andere of document.querySelectorAll('.call-brett .gruppe')) {
|
||
if (andere === s) continue;
|
||
const kopfA = andere.querySelector('.stufe__kopf');
|
||
const koerperA = andere.querySelector('.gruppe__koerper');
|
||
if (!kopfA || !koerperA || andere.dataset.zu === 'ja') continue;
|
||
andere.dataset.zu = 'ja';
|
||
kopfA.setAttribute('aria-expanded', 'false');
|
||
koerperA.hidden = true;
|
||
try {
|
||
localStorage.setItem(`calls:${andere.dataset.gruppe}`, 'zu');
|
||
} catch { /* egal */ }
|
||
}
|
||
}
|
||
});
|
||
|
||
/* DIE ANSTEHENDEN WERDEN NACH ZEITRAUM UNTERTEILT (06.09.2026).
|
||
Die anderen beiden Gruppen nicht: "Protokoll fehlt" ist eine
|
||
Arbeitsliste, die man ganz sehen will, und "Festgehalten" ist
|
||
ohnehin schon ein Archiv. Eine Unterteilung, die überall gleich
|
||
aussieht, aber nur an einer Stelle etwas nützt, verwirrt an den
|
||
anderen. */
|
||
if (g.schluessel === 'anstehend' && liste.length > 4) {
|
||
koerper.append(...zeitraumBloecke(liste, g.schluessel));
|
||
} else {
|
||
const box = el('div', 'gruppe__karten');
|
||
for (const c of liste) box.append(karte(c, g.schluessel));
|
||
koerper.append(box);
|
||
}
|
||
s.append(koerper);
|
||
stand(zu);
|
||
ziel.append(s);
|
||
}
|
||
}
|
||
|
||
/** Baut die aufklappbaren Zeitraum-Blöcke.
|
||
*
|
||
* <details> und nicht selbstgebaut: bringt Tastaturbedienung,
|
||
* aria-expanded und das Verhalten der Browsersuche von sich aus mit.
|
||
* Dasselbe Bauteil wie beim Bereich "Abgebrochen" auf dem
|
||
* Aufgabenbrett -- wer eines kennt, kennt beide. */
|
||
function zeitraumBloecke(liste, gruppe) {
|
||
const nach = new Map(ZEITRAEUME.map((z) => [z.schluessel, []]));
|
||
for (const c of liste) nach.get(zeitraumVon(c.beginn))?.push(c);
|
||
|
||
const raus = [];
|
||
let ersterMitInhalt = true;
|
||
for (const z of ZEITRAEUME) {
|
||
const eigene = nach.get(z.schluessel);
|
||
if (!eigene.length) continue;
|
||
|
||
const block = el('details', 'zeitblock');
|
||
block.dataset.zeitraum = z.schluessel;
|
||
/* Nur der erste gefüllte Block ist offen -- siehe Begründung
|
||
oben bei ZEITRAEUME. */
|
||
if (ersterMitInhalt) { block.open = true; ersterMitInhalt = false; }
|
||
|
||
const kopf = el('summary', 'zeitblock__kopf');
|
||
kopf.append(el('span', 'zeitblock__name', z.titel));
|
||
kopf.append(el('span', 'zeitblock__zahl', String(eigene.length)));
|
||
/* Wann genau -- "Später" allein sagt nicht, ob morgen oder im
|
||
November. Der erste Termin des Blocks beantwortet das. */
|
||
const erster = eigene[0];
|
||
if (erster) {
|
||
kopf.append(el('span', 'zeitblock__ab',
|
||
z.schluessel === 'spaeter' ? `ab ${kurzDatum(erster.beginn)}` : ''));
|
||
}
|
||
block.append(kopf);
|
||
|
||
const box = el('div', 'gruppe__karten');
|
||
for (const c of eigene) box.append(karte(c, gruppe));
|
||
block.append(box);
|
||
raus.push(block);
|
||
}
|
||
return raus;
|
||
}
|
||
|
||
/** "23. Sep" -- kurz, weil es neben einer Überschrift steht. */
|
||
function kurzDatum(beginn) {
|
||
const d = new Date(String(beginn).replace(' ', 'T'));
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
async function laden() {
|
||
try {
|
||
const a = await hole('/workspace/api/calls');
|
||
if (!a.ok) { melde('Gespräche konnten nicht geladen werden.'); return; }
|
||
daten = await a.json();
|
||
raumZeigen();
|
||
zeichne();
|
||
} catch { /* umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Der feste Call-Raum ------------------------------------------
|
||
Sichtbar für alle, änderbar nur vom Management. Wer nicht ändern
|
||
darf und wo noch nichts hinterlegt ist, bekommt die Zeile gar nicht
|
||
zu sehen -- eine leere Angabe, an der man nichts tun kann, ist nur
|
||
Rauschen. */
|
||
|
||
const darfRaum = () => LEITUNG.has(ich?.rolle);
|
||
|
||
function raumZeigen() {
|
||
const link = daten?.call_link || '';
|
||
const block = $('call-raum');
|
||
block.hidden = !link && !darfRaum();
|
||
$('call-raum-aendern').hidden = !darfRaum();
|
||
$('call-raum-link').hidden = !link;
|
||
$('call-raum-leer').hidden = !!link;
|
||
if (link) {
|
||
$('call-raum-link').href = link;
|
||
/* Der nackte Link ist unlesbar lang. Angezeigt wird, woran man ihn
|
||
erkennt -- die volle Adresse steht im Titel und im Verweis. */
|
||
$('call-raum-link').textContent = link.replace(/^https?:\/\//i, '').slice(0, 60);
|
||
$('call-raum-link').title = link;
|
||
}
|
||
}
|
||
|
||
$('call-raum-aendern').addEventListener('click', () => {
|
||
$('call-raum-feld').value = daten?.call_link || '';
|
||
$('call-raum-form').hidden = false;
|
||
$('call-raum-aendern').hidden = true;
|
||
$('call-raum-feld').focus();
|
||
});
|
||
$('call-raum-abbrechen').addEventListener('click', () => {
|
||
$('call-raum-form').hidden = true;
|
||
$('call-raum-aendern').hidden = false;
|
||
});
|
||
$('call-raum-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
melde('');
|
||
try {
|
||
const a = await hole('/workspace/api/call-link', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ link: $('call-raum-feld').value.trim() }),
|
||
});
|
||
if (!a.ok) {
|
||
melde(sagWas((await a.json().catch(() => ({}))).fehler, 'Speichern ging nicht.'));
|
||
return;
|
||
}
|
||
$('call-raum-form').hidden = true;
|
||
$('call-raum-aendern').hidden = false;
|
||
await laden();
|
||
} catch { /* umgeleitet */ }
|
||
});
|
||
|
||
/* ---------- Start --------------------------------------------------------- */
|
||
|
||
(async () => {
|
||
try {
|
||
const a = await hole('/workspace/api/ich');
|
||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||
ich = await a.json();
|
||
} catch {
|
||
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
|
||
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
|
||
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
|
||
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
|
||
Knopf an, der es noch einmal versucht. */
|
||
window.ladefehler?.();
|
||
return;
|
||
}
|
||
window.werZeigen?.(ich);
|
||
kiDa = await window.KI.verfuegbar();
|
||
await laden();
|
||
})();
|
||
})();
|