Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung

Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-02 15:14:47 +02:00
1 parent bc30f2900b
commit db63a29365
47 files changed
+16486 -3768

No files matched your search

+223 -32
View File
@@ -246,7 +246,7 @@ function getWeekNumber(d){
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
// Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der
// Modul. Nur der
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
// sie unterschiedliche Speicherpfade ansteuern.
@@ -289,10 +289,27 @@ function formatStatus(command, value){
// eingeschleust werden können; im style-Attribut sind nur color/font-weight
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
// leerem Eingabewert).
//
// DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn
// dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete
// Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar
// nicht im Dokument hängt. Ein `<img src=x onerror=…>` lief damit schon
// WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier
// durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server
// speichert Kommentare und Protokolltexte unverändert, ein angemeldetes
// Konto konnte solches HTML also über die API hinterlegen und damit Code in
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
// feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum.
function sanitizeStatusHtml(html){
if(!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
// Knoten vorher in ein Element des Hauptdokuments übernommen, zöge
// appendChild() ein adoptNode() nach sich — und ein dabei übernommenes
// <img src=…> begänne wieder zu laden, also genau das, was hier
// verhindert werden soll.
const inert = new DOMParser().parseFromString(String(html), 'text/html');
const div = inert.body;
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
const allowedAttrs = new Set(['style']);
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
@@ -345,26 +362,27 @@ function sanitizeStatusHtml(html){
return div.innerHTML;
}
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
// funktionieren und Altbestände lesbar bleiben.
// statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei
// die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu
// statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen
// einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE
// ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js)
// schlägt daraus den Titel einer neuen Aufgabe vor.
//
// Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab
// Fett als **Sternchen**, Kursiv als *Sternchen* und Farbe als
// [[c:…]]…[[/c]] zurück — die Rückrichtung zum damaligen Markup-Editor.
// Seit der Editor echtes HTML führt (richtextWickeln() unten), hat dieses
// Markup keinen Leser mehr; übrig blieben Sternchen, die unvermittelt im
// vorgeschlagenen Aufgabentitel standen, obwohl sie nie jemand getippt hat.
// Deshalb liefert die Funktion jetzt schlichten Text.
// Parameter: html (String). Rückgabe: String.
function statusHtmlToEditText(html){
function statusTextMitZeilen(html){
if(!html) return '';
const div = document.createElement('div');
div.innerHTML = sanitizeStatusHtml(html);
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
div.querySelectorAll('b, strong').forEach(el => {
const inner = (el.textContent || '').trim();
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
});
// Block-Grenzen zu Zeilenumbrüchen
// Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch
// textContent ohnehin weg.
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
return (div.textContent || '')
@@ -373,19 +391,192 @@ function statusHtmlToEditText(html){
.trim();
}
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
// Parameter: text (String). Rückgabe: String (HTML).
function statusEditTextToHtml(text){
const clean = String(text || '').trim();
if(clean === '') return '';
return esc(clean)
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
.replace(/\n/g, '<br>');
// ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ----------
//
// Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen
// <textarea>, inzwischen vollständig entfernt) zeigte während des
// Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter
// Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text
// richtig fett machen". Diese Felder sind jetzt contenteditable statt
// <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/
// richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über
// die Selection/Range-API (range.extractContents()/insertNode()), bewusst
// KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige
// contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur
// diesmal ohne execCommand). Der Nutzer sieht dadurch sofort echten fetten/
// kursiven/farbigen Text, keine Markup-Zeichen mehr.
// richtextToolbarHtml() liefert die dazugehörige, überall wiederverwendbare
// Werkzeugleiste; sie erwartet, dass das Zielfeld im selben .richtext-wrap
// steht (per closest()+querySelector() gefunden), damit mehrere Instanzen
// auf einer Seite (z. B. je Protokoll-Punkt) einander nicht in die Quere
// kommen. onmousedown mit preventDefault() verhindert, dass der Knopf-Klick
// dem Feld vorher den Fokus (und damit die Markierung) stiehlt.
function richtextWickeln(feld, tagName, styleAttr){
if(!feld) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return;
const wrapper = document.createElement(tagName);
if(styleAttr) wrapper.setAttribute('style', styleAttr);
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
// Markierung auf den neu eingefügten Inhalt setzen — ein zweiter Klick auf
// eine andere Auszeichnung wirkt dadurch sofort auf denselben Text weiter.
sel.removeAllRanges();
const neueRange = document.createRange();
neueRange.selectNodeContents(wrapper);
sel.addRange(neueRange);
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
function richtextFett(feld){ richtextWickeln(feld, 'b'); }
function richtextKursiv(feld){ richtextWickeln(feld, 'i'); }
function richtextFarbe(feld, farbe){ richtextWickeln(feld, 'span', 'color:' + farbe); }
// richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext
// (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein
// <textarea>. html MUSS bereits sanitizeStatusHtml()-bereinigt sein (niemals
// escapter Klartext — der Inhalt soll als Markup interpretiert werden, nicht
// als Zeichenkette). extraAttrs hängt aufruferspezifische Event-Handler an.
// editable=false (gesperrte Sitzung) setzt contenteditable="false" statt das
// Feld ganz durch reinen Text zu ersetzen — Auswahl/Kopieren bleibt möglich.
// Parameter: cssClass, html, placeholder, extraAttrs (Strings), editable
// (Boolean, optional, Default true). Rückgabe: HTML-String.
function richtextFeldHtml(cssClass, html, placeholder, extraAttrs, editable){
const bearbeitbar = editable === false ? 'false' : 'true';
return `<div class="${cssClass}" contenteditable="${bearbeitbar}" data-placeholder="${esc(placeholder || '')}" ${extraAttrs || ''}>${html || ''}</div>`;
}
// richtextCursorAnsEnde: setzt den Fokus in ein contenteditable-Feld und den
// Cursor ans Textende — das Gegenstück zu textarea.setSelectionRange(), das
// es für contenteditable nicht gibt. Parameter: feld (Element). Keine
// Rückgabe.
function richtextCursorAnsEnde(feld){
if(!feld) return;
feld.focus();
const range = document.createRange();
range.selectNodeContents(feld);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// Einfügen bereinigen: Ohne diesen Lauscher übernimmt ein contenteditable-Feld
// beim Einfügen aus Word, einer Webseite oder einer Mail deren vollständige
// Formatierung — Schriftarten, Größen, Tabellen, Bilder. Sichtbar blieb das
// bis zum Speichern; erst dort lief sanitizeStatusHtml() und der Text sprang
// unvermittelt in eine andere Gestalt. Jetzt wird schon beim Einfügen auf
// dieselbe Positivliste reduziert, die auch gespeichert wird — was man sieht,
// ist, was bleibt.
//
// EIN Lauscher am Dokument für alle Felder (gleiche Begründung wie beim
// selectionchange-Lauscher in js/plan/plan-protokoll.js): Die Felder werden
// beim Rendern ständig neu erzeugt, ein Lauscher je Feld müsste bei jedem
// Neuaufbau erneut gesetzt werden.
document.addEventListener('paste', function(e){
const feld = e.target && e.target.closest ? e.target.closest('[contenteditable="true"]') : null;
if(!feld || !e.clipboardData) return;
e.preventDefault();
// text/html nur, wenn vorhanden — reiner Text wird escaped eingesetzt,
// damit darin enthaltene spitze Klammern Zeichen bleiben und nicht als
// Markup ankommen.
const html = e.clipboardData.getData('text/html');
const sauber = html
? sanitizeStatusHtml(html)
: esc(e.clipboardData.getData('text/plain')).replace(/\r\n|\r|\n/g, '<br>');
if(!sauber) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
// Über ein <template> in Knoten verwandeln: dessen Inhalt liegt in einem
// eigenen, inerten Dokumentfragment. `sauber` ist zwar bereits geprüft,
// aber so bleibt auch dieser Schritt frei von Ressourcen-Ladevorgängen.
const vorlage = document.createElement('template');
vorlage.innerHTML = sauber;
const fragment = vorlage.content;
const letzter = fragment.lastChild;
range.insertNode(fragment);
// Schreibmarke hinter das Eingefügte setzen, sonst stünde sie davor.
if(letzter){
const nach = document.createRange();
nach.setStartAfter(letzter);
nach.collapse(true);
sel.removeAllRanges();
sel.addRange(nach);
}
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
// Rückmeldung der Formatierungsleiste: Fett/Kursiv/Farbe wirken nur auf
// MARKIERTEN Text — ohne Markierung passiert beim Klick nichts, und zwar ohne
// jeden Hinweis warum. Die Leiste hebt sich deshalb sichtbar hervor, sobald im
// zugehörigen Feld etwas markiert ist, und trägt daneben einen Hinweis, der
// von "Erst Text markieren" auf die Bestätigung umspringt.
//
// EIN dokumentweiter Lauscher für JEDES Vorkommen von .richtext-wrap, nicht
// nur für den Protokoll-Schreibbereich (wo diese Funktion ursprünglich nur
// dort lebte): der Board-Kommentar nutzt dieselbe Leiste
// (richtextToolbarHtml(), renderCommentEditor() in js/board/board.js), zeigte
// die Markierung aber nie an — die Leiste blieb dort dauerhaft blass, egal ob
// Text markiert war. Anders als im Protokoll (immer höchstens EIN
// Schreibbereich) können im Projektboard mehrere Zeilen gleichzeitig
// aufgeklappt sein, jede mit eigenem Kommentarfeld — der Lauscher geht
// deshalb jedes .richtext-wrap einzeln durch statt nur eines anzunehmen.
document.addEventListener('selectionchange', function(){
const sel = window.getSelection();
const bereich = (sel && sel.rangeCount > 0 && !sel.isCollapsed) ? sel.getRangeAt(0) : null;
document.querySelectorAll('.richtext-wrap').forEach(wrap => {
const leiste = wrap.querySelector('.richtext-toolbar');
const feld = wrap.querySelector('[contenteditable="true"]');
if(!leiste || !feld) return;
const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer));
leiste.classList.toggle('hat-auswahl', markiert);
const hinweis = leiste.querySelector('.richtext-hinweis');
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren';
});
});
// RICHTEXT_FARBEN: die Palette der Farbknöpfe — bewusst dieselben Werte wie
// --red/--green/--accent/--amber (styles.css), damit eine im Text gewählte
// Farbe dieselbe Bedeutung trägt wie anderswo in der Anwendung (Rot = Achtung,
// Grün = erledigt/positiv, Blau = Hinweis, Orange = offen/Warnung).
const RICHTEXT_FARBEN = [
{ farbe: '#d70015', titel: 'Rot' },
{ farbe: '#248a3d', titel: 'Grün' },
{ farbe: '#0071e3', titel: 'Blau' },
{ farbe: '#b25000', titel: 'Orange' }
];
// richtextToolbarHtml: liefert die Werkzeugleiste (Fett/Kursiv/Farben) für ein
// contenteditable-Feld (richtextFeldHtml()), das im selben .richtext-wrap-
// Container steht. targetSelector wählt das Zielfeld innerhalb dieses
// Containers aus — jeder Aufrufer übergibt seine eigene Feldklasse (z. B.
// '.comment-field'), der Default greift nur, falls das je vergessen wird.
// Parameter: targetSelector (String, optional). Rückgabe: HTML-String.
function richtextToolbarHtml(targetSelector){
const sel = targetSelector || '[contenteditable]';
const ziel = `this.closest('.richtext-wrap').querySelector('${sel}')`;
const farbButtons = RICHTEXT_FARBEN.map(f =>
`<button type="button" class="richtext-farbe-btn" style="background:${f.farbe}" title="${f.titel}"
onmousedown="event.preventDefault()" onclick="richtextFarbe(${ziel}, '${f.farbe}')"></button>`).join('');
// .richtext-hinweis: Fett/Kursiv/Farbe wirken nur auf MARKIERTEN Text —
// ohne Markierung tut ein Klick nichts, und das war bisher nirgends
// ablesbar. Der Hinweis steht deshalb fest in der Leiste; wer ihn
// aktualisieren will, wenn tatsächlich etwas markiert ist, hängt sich an
// selectionchange (so macht es der Protokoll-Schreibbereich,
// js/plan/plan-protokoll.js).
return `<div class="richtext-toolbar">
<span class="richtext-btn-group">
<button type="button" class="richtext-btn" title="Fett" onmousedown="event.preventDefault()" onclick="richtextFett(${ziel})"><b>F</b></button>
<button type="button" class="richtext-btn" title="Kursiv" onmousedown="event.preventDefault()" onclick="richtextKursiv(${ziel})"><i>K</i></button>
</span>
<span class="richtext-divider"></span>
<span class="richtext-farben">${farbButtons}</span>
<span class="richtext-hinweis">Erst Text markieren</span>
</div>`;
}
// -------- Projekt-Verlauf über mehrere Sitzungen --------