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

+836
View File
@@ -0,0 +1,836 @@
// -----------------------------
// Projektübersicht — Wandtafel (Touch-Dashboard)
// -----------------------------
// Modul für den Tab "Wandtafel" (App.html, Ladereihenfolge: ... → load →
// dashboard → wand → settings → ...). Ersetzt den früheren Show Mode: der
// klonte nur die gerade gerenderte Board- bzw. Auslastungs-Ansicht der EINEN
// angemeldeten Sitzung (kein eigener Datenpfad, keine Aggregation über
// Gewerke oder Nutzer hinweg). Gedacht für ein dauerhaft laufendes
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
// CLAUDE.md "Wandtafel".
//
// Zweispaltig (Eriks Wahl "Variante C" aus dem Layout-Vergleich): links drei
// kompakte Kennzahl-Kacheln, rechts der Ereignis-Feed — seit einem zweiten,
// eigenen Vergleich (Feed-Vergleich) nach Art in drei Spalten gruppiert
// (Neu/Überfällig/Termine) statt chronologisch gemischt, siehe
// wandFeedHtml(). Die Kacheln selbst sind NICHT antippbar (kein Auf-/Zuklappen
// mehr, auf Eriks Wunsch) — stattdessen ist jedes einzelne darin dargestellte
// Element für sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel,
// ein Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
// Wiederverwendet werden berechneAuslastung()/loadAmpelColor()/
// kapazitaetsFaktor()/stundenFormat()/lpActiveList() aus js/load/load.js,
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
// js/board/board.js, relativerTag()/tageBis()/initialsFor()/
// formatWeekDate() aus js/dashboard/dashboard.js bzw. js/utils.js,
// GROUP_ORDER/ROLES aus js/globals.js, sortGroups()/peopleByRole() aus
// js/utils.js, esc()/heuteIso() aus js/utils.js, currentWeek() aus
// js/weeks.js, load()/baseUrl() aus js/store.js/js/server.js,
// showLoginOverlay() aus js/auth.js, openProjectPlan() aus js/gantt.js
// (jeder Sprung ins Projekt verlässt die Wandtafel bewusst in den normalen
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
// jedem anderen Tab-Wechsel auch).
//
// Die Normalansicht ist höher als ein Bildschirm: unterhalb der zwei
// Kennzahl-Spalten folgt die vollständige Projektliste
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
// renderBoardRow()/BOARD_OPTS wie dort, nur mit locked FEST auf true (siehe
// wandProjektlisteHtml()) — die Wandtafel schreibt nie, also gibt es hier
// nie Ziehgriffe oder einen Löschen-Knopf, unabhängig vom echten Sperrstatus
// der Sitzung. Das Display ist ein echter Touch-Monitor — Erik kann
// davortreten und darin scrollen; ohne Berührung kehrt die Ansicht nach
// WAND_IDLE_MS von selbst an den Anfang zurück (wandAktivitaetErkannt()/
// wandBeiUntaetigkeitZuruecksetzen()), damit ein unbeaufsichtigtes Display
// nicht dauerhaft im Zustand der letzten vorbeigehenden Person hängen
// bleibt. Erik wollte diesen Rücksprung nicht nur erleben, sondern vorher
// sehen können: #wandIdleCountdown (neben "← Zur Übersicht", App.html) zeigt
// laufend die verbleibende Zeit (wandCountdownTick(), ein eigenes
// 1-Sekunden-Intervall, siehe startWandboard()/stopWandboard()).
// Dasselbe gilt auch AUSSERHALB der Wandtafel: Ein Klick auf eine
// Projekt-/Gewerk-Fläche verlässt sie ja absichtlich (Projektbereich bzw.
// Wochenbericht) — ein document-weiter, capturing Klick-Lauscher auf
// #wandContent setzt dabei wandUnterwegs, worüber dieselbe globale
// Aktivitätserkennung (wandAktivitaetErkannt()) statt der Untätigkeits- die
// Rückkehr-Uhr bedient; nach WAND_IDLE_MS ohne Berührung dort kehrt
// wandKehreZurueck() von selbst auf die Wandtafel zurück. Der bewusste
// Ausstieg über "← Zur Übersicht" (außerhalb von #wandContent) setzt
// wandUnterwegs NICHT — eine Vorschau durch Personal soll nicht nach einer
// Minute unterbrochen werden.
// WAND_GEWERKE: die drei Fachrichtungen, in dieser Reihenfolge überall auf
// der Wandtafel. Dieselben Werte wie VALID_DEPARTMENTS/project_gewerke.
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
const WAND_FEED_MAX = 5; // Höchstzahl Einträge JE SPALTE im Ereignis-Feed (Neu/Überfällig/Termine)
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
// die Ansicht von selbst in den Normalzustand zurück (oben, keine offene
// Übersicht) — ein unbeaufsichtigtes Display darf nicht dauerhaft in einem
// Zustand hängen bleiben, den zufällig die letzte vorbeigehende Person
// hinterlassen hat.
const WAND_IDLE_MS = 60000;
let wandRefreshTimer = null;
let wandIdleTimer = null;
let wandCountdownTimer = null;
let wandIdleDeadline = 0; // Date.now()-Zeitpunkt, zu dem die Untätigkeits-Uhr auslöst
let wandRueckkehrTimer = null;
// wandUnterwegs: true zwischen einem Klick auf eine Projekt-/Gewerk-Fläche
// DER WANDTAFEL (gesetzt vom Klick-Lauscher auf #wandContent weiter unten)
// und der Rückkehr dorthin — nicht beim Verlassen über "← Zur Übersicht"
// (ein bewusster Ausstieg für eine Vorschau, kein versehentliches Antippen,
// und außerhalb von #wandContent). Nur während dieser Zeit treibt
// wandAktivitaetErkannt() die Rückkehr-Uhr statt der Untätigkeits-Uhr.
let wandUnterwegs = false;
let wandAktuelleWoche = null; // für Übersicht-Klicks gemerkt (re-render ohne erneutes Laden)
// wandUebersicht: null (zweispaltige Normalansicht) oder {typ, gewerk?} —
// welche Vollbild-Übersicht gerade anstelle der Normalansicht steht. gewerk
// ist nur bei typ 'auslastung'/'besprochen' gesetzt (eine Übersicht je
// Gewerk).
let wandUebersicht = null;
// startWandboard: startet die Hintergrund-Aktualisierung, falls nicht schon
// aktiv. Der Schon-aktiv-Schutz ist kein Komfort, sondern notwendig: load()
// ruft an seinem Ende selbst showTab(currentTab) auf — showTab('wand') riefe
// ohne diesen Schutz bei JEDEM eigenen 60s-Takt erneut startWandboard() auf
// und setzte damit sofort einen weiteren, ungeduldigen Takt in Gang (eine
// Rückkopplungsschleife, die praktisch ohne Pause neu lädt). Nach einer
// erneuten Anmeldung (stopWandboard() setzt wandRefreshTimer zuvor auf null,
// siehe wandRefreshTick()) greift der Schutz dagegen nicht und die
// Aktualisierung startet korrekt neu. Keine Parameter, keine Rückgabe.
function startWandboard(){
if(wandRefreshTimer) return;
// Ein frisches Betreten beginnt immer bei der normalen Ansicht, nie in
// einer Übersicht hängen — die läuft nur innerhalb EINES Aufenthalts auf
// der Wandtafel weiter (der 60s-Hintergrund-Takt ruft startWandboard()
// zwar erneut auf, der Schutz oben greift dann aber schon vorher, sodass
// dieser Reset NICHT bei jedem Takt läuft). Eine evtl. noch laufende
// Rückkehr-Uhr endet hier ebenfalls — sie ist erfüllt, sobald die
// Wandtafel wieder aktiv ist, egal ob durch sie selbst ausgelöst oder
// durch eine andere Navigation dorthin.
wandUebersicht = null;
wandUnterwegs = false;
clearTimeout(wandRueckkehrTimer); wandRueckkehrTimer = null;
wandRefreshTick();
wandRefreshTimer = setInterval(wandRefreshTick, WAND_REFRESH_MS);
wandAktivitaetErkannt(); // startet die Untätigkeits-Uhr für diesen Aufenthalt
wandCountdownTick();
wandCountdownTimer = setInterval(wandCountdownTick, 1000);
}
// stopWandboard: beendet den Hintergrund-Takt der Wandtafel. Wird von
// showTab() beim Verlassen des Tabs aufgerufen (NACH dem Entfernen von
// body.wandtafel-aktiv, siehe tabs-auth.js). Wenn das Verlassen durch einen
// Klick INNERHALB von #wandContent ausgelöst wurde (wandUnterwegs, siehe der
// document-weite Klick-Lauscher unten), wird hier sofort die Rückkehr-Uhr
// scharf gestellt — wandAktivitaetErkannt() nimmt wegen des bereits
// entfernten wandtafel-aktiv automatisch den passenden Zweig. Ein Verlassen
// OHNE vorherigen Klick (etwa stopWandboard() aus wandRefreshTick() bei
// abgelaufener Sitzung) lässt wandUnterwegs unberührt — dort gibt es nichts,
// wohin zurückzukehren wäre. Keine Parameter, keine Rückgabe.
function stopWandboard(){
clearInterval(wandRefreshTimer); wandRefreshTimer = null;
clearTimeout(wandIdleTimer); wandIdleTimer = null;
clearInterval(wandCountdownTimer); wandCountdownTimer = null;
const el = document.getElementById('wandIdleCountdown');
if(el) el.textContent = '';
if(wandUnterwegs) wandAktivitaetErkannt();
}
// wandAktivitaetErkannt: setzt je nach Lage entweder die Untätigkeits-Uhr
// (auf der Wandtafel selbst) oder die Rückkehr-Uhr (unterwegs, siehe
// wandUnterwegs) zurück. Horcht global auf Berührung/Maus/Tastatur/Scroll —
// so kann der Listener einmalig beim Laden des Skripts registriert werden,
// statt bei jedem Betreten/Verlassen an- und abgemeldet zu werden, und ist
// überall sonst (kein wandtafel-aktiv, nicht wandUnterwegs) ein reines
// No-op. Keine Parameter, keine Rückgabe.
function wandAktivitaetErkannt(){
if(document.body.classList.contains('wandtafel-aktiv')){
clearTimeout(wandIdleTimer);
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
wandCountdownTick();
} else if(wandUnterwegs){
clearTimeout(wandRueckkehrTimer);
wandRueckkehrTimer = setTimeout(wandKehreZurueck, WAND_IDLE_MS);
}
}
// wandKehreZurueck: nach WAND_IDLE_MS ohne Berührung AUSSERHALB der
// Wandtafel — ausgelöst durch einen Klick auf eine ihrer eigenen
// Projekt-/Gewerk-Flächen (wandUnterwegs, siehe dort) — zurück zur
// Wandtafel. Erik: "auch wenn ich eine der Flächen/Projekte auf der Tafel
// anklicke soll nach 1 min das Bild wieder zurückspringen" — ein kurzer
// Blick ins Projekt darf das unbeaufsichtigte Display nicht dauerhaft
// woanders hinterlassen. Keine Parameter, keine Rückgabe.
function wandKehreZurueck(){
wandRueckkehrTimer = null;
showTab('wand');
}
// wandCountdownTick: schreibt die verbleibende Zeit bis zum automatischen
// Rücksprung in #wandIdleCountdown (Kopfzeile neben "← Zur Übersicht", siehe
// App.html) — Erik wollte sehen können, wann das passiert, statt vom Sprung
// überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt
// über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert.
// Keine Parameter, keine Rückgabe.
function wandCountdownTick(){
const el = document.getElementById('wandIdleCountdown');
if(!el) return;
const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000));
el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : '';
}
// wandBeiUntaetigkeitZuruecksetzen: nach WAND_IDLE_MS ohne Berührung zurück in
// den Normalzustand — eine offene Übersicht schließt sich, und die
// Projektliste scrollt an den Anfang zurück. Unbedingt (nicht nur wenn gerade
// eine Übersicht offen war): Auch ein bloßes Herunterscrollen in der
// Projektliste soll sich nach einer Minute von selbst zurücksetzen.
// Startet die Uhr am Ende selbst neu (wandAktivitaetErkannt()), statt sie
// auslaufen zu lassen — die Anzeige in #wandIdleCountdown soll durchgehend
// laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand
// die Tafel berührt. Keine Parameter, keine Rückgabe.
function wandBeiUntaetigkeitZuruecksetzen(){
wandUebersicht = null;
renderWandboard({ resetScroll: true });
wandAktivitaetErkannt();
}
['pointerdown', 'touchstart', 'wheel', 'keydown'].forEach(evt =>
document.addEventListener(evt, wandAktivitaetErkannt, { passive: true }));
// 'scroll' bubbelt bei Elementen (anders als bei window/document) nicht —
// ein Listener in der CAPTURING-Phase an document bekommt ihn trotzdem mit,
// da die Capturing-Phase unabhängig vom Bubbling jedes Element auf dem Weg
// zum Ziel durchläuft.
document.addEventListener('scroll', wandAktivitaetErkannt, { passive: true, capture: true });
// Jeder Klick INNERHALB von #wandContent markiert "unterwegs" — unabhängig
// davon, über welche konkrete Funktion er die Wandtafel verlässt
// (openProjectPlan() direkt aus renderBoardRow() in der Projektliste,
// springZumWochenbericht() aus der Besprochen-Übersicht, wandSpringeZuProjekt()
// aus den übrigen Übersichten/dem Feed). Ein einzelner, allgemeiner Lauscher
// statt eigener Wrapper um jede dieser Navigationsfunktionen: Mehrere davon
// werden auch von ganz anderen, nicht-Wandtafel-Stellen der App aufgerufen
// (board.js, plan-info.js) und dürfen dort keine Rückkehr auslösen — hier
// entscheidet allein, WO der Klick stattfand, nicht WELCHE Funktion er
// aufruft. Läuft in der CAPTURING-Phase: Mehrere Klick-Handler in der
// Projektliste rufen event.stopPropagation() auf (z. B. der Projektname in
// renderBoardRow()), was einen Lauscher in der normalen Bubbling-Phase an
// #wandContent gar nicht erst erreichen würde — die Capturing-Phase läuft
// davon unabhängig schon auf dem Weg zum eigentlichen Ziel. Das bewusste
// Verlassen über "← Zur Übersicht" (außerhalb von #wandContent, siehe
// App.html) setzt wandUnterwegs ABSICHTLICH NICHT — eine Vorschau durch
// Personal soll nicht nach einer Minute unterbrochen werden. Die eigentliche
// Uhr wird erst in stopWandboard() scharf gestellt, sobald die Navigation
// tatsächlich stattgefunden hat.
document.addEventListener('click', function(ev){
const wandContent = document.getElementById('wandContent');
if(wandContent && wandContent.contains(ev.target)) wandUnterwegs = true;
}, { capture: true });
// wandRefreshTick: der äußere Hintergrund-Takt. Prüft zuerst GET /whoami statt
// blind load() aufzurufen: Ist die Sitzung abgelaufen, zeigt er das
// bestehende Login-Overlay an Ort und Stelle (dasselbe Formular ist seit dem
// ersten Seitenaufruf schon verdrahtet, siehe checkAuthAndInit(),
// js/auth.js) — eine erneute Anmeldung setzt die Wandtafel über denselben
// onAuthenticated()-Weg fort, ohne dass die Seite neu geladen werden muss.
// Keine Parameter, keine Rückgabe.
async function wandRefreshTick(){
let data;
try {
const res = await fetch(baseUrl() + '/whoami');
data = await res.json();
} catch(err){
console.error('Wandtafel: Anmeldestatus konnte nicht geprüft werden:', err);
return; // nächster Takt versucht es erneut
}
if(!data || !data.user){
stopWandboard();
showLoginOverlay();
return;
}
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
}
// renderWandboard: baut entweder die zweispaltige Normalansicht (mit der
// vollständigen Projektliste darunter) oder eine offene Vollbild-Übersicht
// auf. Wird von showTab('wand') sowie nach jedem load() (über
// showTab(currentTab)) aufgerufen.
//
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
// verstauen, z. B. die gesamte Projektliste". Nur dieser Teil scrollt
// (`.wand-scrollable` auf #wandContent); eine offene Übersicht bleibt wie
// zuvor genau einen Bildschirm hoch mit ihrer eigenen inneren Liste. Der
// Scroll-Stand bleibt über den 60-Sekunden-Hintergrund-Takt hinweg erhalten
// (sonst risse ein laufender Refresh jemanden, der gerade in der Liste
// scrollt, zurück an den Anfang) — nur wandBeiUntaetigkeitZuruecksetzen()
// verlangt ausdrücklich resetScroll:true. Parameter: opts.resetScroll
// (optional). Keine Rückgabe.
function renderWandboard(opts){
const resetScroll = !!(opts && opts.resetScroll);
const container = document.getElementById('wandContent');
if(!container) return;
const w = currentWeek();
if(!w){
container.innerHTML = '<div class="hint">Keine Sitzung vorhanden.</div>';
return;
}
wandAktuelleWoche = w;
const vorherigerScroll = resetScroll ? 0 : container.scrollTop;
if(wandUebersicht){
container.classList.remove('wand-scrollable');
container.innerHTML = wandUebersichtHtml(w);
return;
}
container.classList.add('wand-scrollable');
container.innerHTML = '<div class="wand-ambient"><div class="wand-c-layout">'
+ '<div class="wand-c-links">' + wandLinkeSpalteHtml(w) + '</div>'
+ '<div class="wand-c-rechts">'
+ '<h3 class="wand-kachel-titel">Aktuell diese Woche</h3>'
+ '<div class="wand-feed-wrap">' + wandFeedHtml(w) + '</div>'
+ renderWandStand()
+ '</div>'
+ '</div></div>'
+ wandProjektlisteHtml(w);
container.scrollTop = vorherigerScroll;
}
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
// sondern dieselbe Zeile wie im echten Tab "Projekte": gleiche Gruppen
// (GROUP_ORDER/sortGroups()), gleiche Sortierung (p.order) und jede Zeile
// über denselben renderBoardRow()+BOARD_OPTS wie renderBoard() selbst
// (js/board/board.js) — Nr., Name, Auftragsstatus-Kennzeichen, Status-
// Vorschau, Avatar-Stapel und Phasen-Badge, aufklappbar zum selben
// Detailpanel (renderBoardDrawer()). Anders als die Auslastung/Besprochen-
// Kacheln zählt hier bewusst auch "In Anbahnung" mit, da es um
// Vollständigkeit geht, nicht um bewertbare Arbeitslast.
//
// `locked` wird FEST auf true übergeben, unabhängig vom tatsächlichen
// Sperrstatus der Sitzung: Die Wandtafel schreibt nie (siehe Dateikopf), und
// renderBoardRow()/renderBoardDrawer() blenden bei locked=true genau die
// Bedienelemente aus, die einen Schreibzugriff auslösen würden (Ziehgriff,
// "Löschen", editierbarer Kommentar) — ein Projekt antippen bleibt möglich
// (öffnet/schließt die Zeile, "Öffnen" springt ins Projekt), ein Kiosk-Konto
// bekäme auf jeden Schreibversuch ohnehin ein serverseitiges 403.
// toggleBoardRow() (board.js) ruft nach jedem Auf-/Zuklappen zusätzlich
// renderWandboard() auf, damit eine hier aufgeklappte Zeile sofort sichtbar
// wird, genau wie es das für renderBoard()/renderWochenbericht() schon tut.
// Parameter: w. Rückgabe: HTML-String.
function wandProjektlisteHtml(w){
const locked = true;
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
const groups = {};
GROUP_ORDER.forEach(g => { groups[g] = []; });
(Array.isArray(w.projects) ? w.projects : []).forEach(p => {
(groups[p.group] || (groups[p.group] = [])).push(p);
});
Object.keys(groups).forEach(g => groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)));
const abschnitte = sortGroups(Object.keys(groups)).map(g => {
if(groups[g].length === 0) return '';
const zeilen = groups[g].map(p => renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS)).join('');
return '<div class="section-title">' + esc(g) + '</div><div class="board-list">' + zeilen + '</div>';
}).join('');
return '<div class="wand-projektliste">'
+ '<h3 class="wand-kachel-titel">Alle Projekte</h3>'
+ abschnitte
+ '</div>';
}
// wandLinkeSpalteHtml: die drei Kennzahl-Kacheln (nicht mehr antippbar —
// nur die Elemente darin sind es). Parameter: w. Rückgabe: HTML-String.
function wandLinkeSpalteHtml(w){
return wandTileAuslastung(w) + wandTileBesprochen(w) + wandTileHeute(w);
}
// wandKachel: die gemeinsame, nicht-interaktive Kachel-Umrahmung. Parameter:
// titel, inhalt (HTML). Rückgabe: HTML-String.
function wandKachel(titel, inhalt){
return '<section class="wand-kachel">'
+ '<h3 class="wand-kachel-titel">' + esc(titel) + '</h3>'
+ '<div class="wand-kachel-body">' + inhalt + '</div>'
+ '</section>';
}
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
// updateRouteUrl()) wie die Termin-Links im Dashboard
// (js/dashboard/dashboard.js), nur als unmittelbarer Bereichswechsel statt
// eines neuen Tabs — die Wandtafel hat nur die eine Ansicht. Der Hash wird
// IMMER gesetzt (auch leer->'dashboard'), damit ein von einem früheren Sprung
// übrig gebliebener Hash nicht fälschlich übernommen wird. (Die Rückkehr zur
// Wandtafel nach einer Minute Untätigkeit — wandUnterwegs — wird nicht hier,
// sondern generisch über den Klick-Lauscher auf #wandContent ausgelöst, da
// die Projektliste über renderBoardRow() auch ganz ohne diese Funktion direkt
// openProjectPlan() aufruft; siehe dort.) Parameter: pid (Projekt-ID),
// abschnitt (Bereichs-Schlüssel, z. B. 'aufgaben'). Keine Rückgabe.
function wandSpringeZuProjekt(pid, abschnitt){
location.hash = abschnitt || 'dashboard';
openProjectPlan(pid);
}
// ---------- Vollbild-Übersichten (gemeinsamer Rahmen) ----------
// wandUebersichtRahmen: Kopfzeile (Titel + Schließen) plus Inhalt — von
// jeder Übersicht genutzt. Parameter: titel, inhalt (HTML). Rückgabe:
// HTML-String.
function wandUebersichtRahmen(titel, inhalt){
return '<div class="wand-uebersicht">'
+ '<div class="wand-uebersicht-kopf"><h2>' + esc(titel) + '</h2>'
+ '<button type="button" class="secondary small" onclick="wandSchliesseUebersicht()">← Zurück zur Wandtafel</button></div>'
+ inhalt
+ '</div>';
}
// wandUebersichtZeile: eine Zeile im gemeinsamen Dreispalten-Format
// (links/mitte/rechts). onclick ist ein fertiger JS-Ausdruck als String oder
// null für eine nicht anklickbare Zeile (z. B. eine Abwesenheit, die auf kein
// Projekt verweist). Parameter: links, mitte, rechts (bereits escapte
// HTML-Strings), onclick, rechtsKlasse (optional, z. B. 'ist-rot').
// Rückgabe: HTML-String.
function wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse){
return '<div class="wand-uebersicht-zeile' + (onclick ? ' ist-klickbar' : '') + '"'
+ (onclick ? ' onclick="' + onclick + '"' : '') + '>'
+ '<span class="wand-uebersicht-projekt">' + links + '</span>'
+ '<span class="wand-uebersicht-titel">' + mitte + '</span>'
+ '<span class="wand-uebersicht-seit' + (rechtsKlasse ? ' ' + rechtsKlasse : '') + '">' + rechts + '</span>'
+ '</div>';
}
// wandZeigeUebersicht: öffnet eine Vollbild-Übersicht anstelle der
// zweispaltigen Ansicht. Parameter: typ (String), gewerk (optional, nur für
// 'auslastung'/'besprochen'). Keine Rückgabe.
function wandZeigeUebersicht(typ, gewerk){
wandUebersicht = { typ, gewerk: gewerk || null };
renderWandboard();
}
// wandSchliesseUebersicht: zurück zur zweispaltigen Ansicht. Keine
// Parameter, keine Rückgabe.
function wandSchliesseUebersicht(){
wandUebersicht = null;
renderWandboard();
}
// wandUebersichtHtml: baut die gerade angeforderte Übersicht. Parameter: w.
// Rückgabe: HTML-String.
function wandUebersichtHtml(w){
const u = wandUebersicht;
if(u.typ === 'ueberfaellig') return wandUebersichtUeberfaellig(w);
if(u.typ === 'offen') return wandUebersichtOffeneAufgaben(w);
if(u.typ === 'abwesend') return wandUebersichtAbwesend(w);
if(u.typ === 'auslastung') return wandUebersichtAuslastung(w, u.gewerk);
if(u.typ === 'besprochen') return wandUebersichtBesprochen(w, u.gewerk);
return '';
}
// ---------- Kachel 1: Auslastung je Gewerk ----------
// wandAuslastungJeGewerk: Wochenstunden, Kapazität UND die beitragenden
// Projekte je Gewerk der aktuellen Sitzung. Rechnet mit denselben Funktionen
// wie renderLoad() (js/load/load.js), nur nach Gewerk statt nach Person
// summiert — dieselbe Stelle im Projekt-Durchlauf, an der auch renderLoad()
// aggregiert. Die Projektliste je Gewerk ist der Rohstoff für die
// Gewerk-Übersicht (wandUebersichtAuslastung()).
//
// Ein Projekt mit MEHREREN Gewerken zählt seinen vollen Beitrag bei JEDEM
// seiner Gewerke — eine bewusste Dopplung, keine Aufteilung: Es gibt keine
// Regel, wie die Stunden zwischen den Gewerken aufzuteilen wären, und eine
// erfundene Aufteilung wäre irreführender als eine offen sichtbare Dopplung.
// Parameter: w (Wochen-Objekt). Rückgabe: { GEWERK: {stunden, kapazitaet,
// bewertbar, gesamt, projekte:[{id,name,nr,stunden}]} }.
function wandAuslastungJeGewerk(w){
const ergebnis = {};
WAND_GEWERKE.forEach(g => { ergebnis[g] = { stunden: 0, kapazitaet: 0, bewertbar: 0, gesamt: 0, projekte: [] }; });
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
(Array.isArray(w.people) ? w.people : []).forEach(p => {
const global = (db.globalPeople || []).find(x => x.id === p.id)
|| (p.fullName ? (db.globalPeople || []).find(x => x.fullName === p.fullName) : null);
const gewerk = global && global.gewerk;
if(!gewerk || !ergebnis[gewerk]) return;
ergebnis[gewerk].kapazitaet += kapazitaetsFaktor(p) * vollzeit;
});
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
if(pr.group === 'In Anbahnung') return;
const gewerke = (Array.isArray(pr.gewerke) ? pr.gewerke : []).filter(g => ergebnis[g]);
if(gewerke.length === 0) return;
gewerke.forEach(g => { ergebnis[g].gesamt++; });
const aktivePhase = lpActiveList(pr)[0];
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
if(!probe.unbewertbar) gewerke.forEach(g => { ergebnis[g].bewertbar++; });
let projektStunden = 0;
Object.keys(pr.assign || {}).forEach(aKey => {
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden > 0) projektStunden += al.stunden;
});
gewerke.forEach(g => {
ergebnis[g].stunden += projektStunden;
if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden });
});
});
return ergebnis;
}
// wandTileAuslastung: ein Meter je Gewerk, Ampelfarbe wie im Auslastungs-Tab.
// Jede Zeile ist einzeln antippbar und öffnet die Projektliste dieses
// Gewerks (wandZeigeUebersicht('auslastung', g)). Eine Null bekommt KEINE
// grüne Farbe (dieselbe Regel wie in renderLoad()) — "keine Daten" statt
// eines irreführend guten Balkens. Parameter: w. Rückgabe: HTML-String.
function wandTileAuslastung(w){
const daten = wandAuslastungJeGewerk(w);
const inhalt = WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
const klick = ' onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')"';
if(d.bewertbar === 0 || d.kapazitaet <= 0){
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><span class="wand-meter-leer">keine Daten</span></div>'
+ '<span class="wand-meter-wert">—</span></div>';
}
const quote = d.stunden / d.kapazitaet;
const ampel = loadAmpelColor(quote);
const breite = Math.min(100, quote * 100);
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + breite + '%;background:' + ampel + '"></div></div>'
+ '<span class="wand-meter-wert" style="color:' + ampel + '">' + stundenFormat(d.stunden) + '<span class="wand-einheit">h</span></span></div>';
}).join('');
return wandKachel('Auslastung je Gewerk', inhalt);
}
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
// nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt
// "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug
// passt inhaltlich am ehesten zur Auslastungs-Frage). Parameter: w, gewerk.
// Rückgabe: HTML-String.
function wandUebersichtAuslastung(w, gewerk){
const daten = wandAuslastungJeGewerk(w)[gewerk];
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
if(!daten || daten.projekte.length === 0){
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-leer">Keine Projekte mit Stunden in diesem Gewerk.</div>');
}
const sortiert = daten.projekte.slice().sort((a, b) => b.stunden - a.stunden);
const zeilen = sortiert.map(p =>
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), stundenFormat(p.stunden) + ' h',
'wandSpringeZuProjekt(\'' + esc(p.id) + '\',\'leistung\')')
).join('');
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Kachel 2: Wochenbericht besprochen ----------
// wandBesprochenDaten: "X von Y besprochen" je Gewerk plus die noch NICHT
// besprochenen Projekte (Rohstoff der Gewerk-Übersicht). "In Anbahnung"
// zählt nicht mit (dieselbe Ausnahme wie in renderLoad()). Parameter: w.
// Rückgabe: { GEWERK: {besprochen, gesamt, offeneProjekte:
// [{id,uid,name,nr}]} }.
function wandBesprochenDaten(w){
const ergebnis = {};
WAND_GEWERKE.forEach(g => {
const relevante = (w.projects || []).filter(p =>
Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung');
const offeneProjekte = relevante.filter(p => !p.besprochen);
ergebnis[g] = {
besprochen: relevante.length - offeneProjekte.length,
gesamt: relevante.length,
offeneProjekte: offeneProjekte.map(p => ({ id: p.id, uid: p.projectUid, name: p.name, nr: p.nr })),
};
});
return ergebnis;
}
// wandTileBesprochen: ein Meter je Gewerk ("X von Y"). Jede Zeile ist
// einzeln antippbar und öffnet die Liste der noch offenen Projekte dieses
// Gewerks (wandZeigeUebersicht('besprochen', g)). Parameter: w. Rückgabe:
// HTML-String.
function wandTileBesprochen(w){
const daten = wandBesprochenDaten(w);
const inhalt = WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
if(d.gesamt === 0){
return '<div class="wand-meter-zeile"><span class="wand-meter-label">' + esc(label) + '</span>'
+ '<span class="wand-meter-leer">keine Projekte</span></div>';
}
const quote = d.besprochen / d.gesamt;
const klick = ' onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')"';
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--accent)"></div></div>'
+ '<span class="wand-meter-wert">' + d.besprochen + ' von ' + d.gesamt + '</span></div>';
}).join('');
return wandKachel('Wochenbericht besprochen', inhalt);
}
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
// Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den
// Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der
// Kommentar/besprochen-Haken wird ausschließlich dort gepflegt ("Ein Ort je
// Angabe", CLAUDE.md), ein Sprung ins Projekt selbst würde nur auf den
// Verweis-Hinweis dorthin treffen. Parameter: w, gewerk. Rückgabe:
// HTML-String.
function wandUebersichtBesprochen(w, gewerk){
const daten = wandBesprochenDaten(w)[gewerk];
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
if(!daten || daten.offeneProjekte.length === 0){
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-leer">Alle Projekte dieses Gewerks sind besprochen.</div>');
}
const zeilen = daten.offeneProjekte.map(p =>
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), 'noch offen',
p.uid ? 'springZumWochenbericht(\'' + esc(p.uid) + '\')' : null)
).join('');
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Kachel 3: Heute (Aufgaben + Abwesenheiten) ----------
// wandHeuteDaten: offene/überfällige Aufgaben UND die heute abwesenden
// Personen in einer Kachel — beides beantwortet dieselbe Frage ("was ist
// heute los"), getrennte Kacheln dafür wären zwei dünne Kacheln nebeneinander
// gewesen. Unternehmensweit über plan.todos ALLER Projekte, anders als GET
// /my-dashboard (nur die eigenen Aufgaben); alle Personen, nicht "außer mir"
// wie bei /my-dashboard — auf der Wandtafel gibt es kein "mich".
// Parameter: w. Rückgabe: {offen, offeneListe:[{titel,projekt,pid,datum}],
// ueberfaelligeListe:[{titel,projekt,pid,datum}],
// abwesend:[{name,typ,bis}]}.
function wandHeuteDaten(w){
const heute = heuteIso();
const offeneListe = [];
const ueberfaelligeListe = [];
(w.projects || []).forEach(pr => {
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
todos.forEach(t => {
if(t.done) return;
const eintrag = {
titel: t.title || 'Aufgabe',
projekt: (pr.nr ? pr.nr + ' ' : '') + pr.name,
datum: t.end || '',
pid: pr.id,
};
offeneListe.push(eintrag);
if(t.end && t.end < heute) ueberfaelligeListe.push(eintrag);
});
});
// Überfällig: am längsten überfällig zuerst. Offen: nächste Frist zuerst,
// Aufgaben ohne Termin ans Ende (dieselbe Reihenfolge wie "Meine Aufgaben"
// im Dashboard).
ueberfaelligeListe.sort((a, b) => a.datum.localeCompare(b.datum));
offeneListe.sort((a, b) => (a.datum || '9999') < (b.datum || '9999') ? -1 : (a.datum || '9999') > (b.datum || '9999') ? 1 : 0);
const abwesend = (db.globalPeople || []).filter(p =>
Array.isArray(p.absences) && p.absences.some(a => a.start <= heute && heute <= a.end)
).map(p => {
const a = p.absences.find(a => a.start <= heute && heute <= a.end);
return { name: p.fullName || p.name, typ: (a && a.type) || 'Abwesend', bis: a && a.end };
});
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
}
// wandTileHeute: zwei Kennzahlen (offen/überfällig) plus Abwesenheits-Kürzel
// — jede einzeln antippbar, kein Auf-/Zuklappen der Kachel mehr. Parameter:
// w. Rückgabe: HTML-String.
function wandTileHeute(w){
const d = wandHeuteDaten(w);
const offenStat = '<div class="wand-stat ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<b>' + d.offen + '</b><span>offene Aufgaben</span></div>';
const ueberfaelligStat = '<div class="wand-stat' + (d.ueberfaelligeListe.length > 0 ? ' ist-rot' : '') + ' ist-klickbar"'
+ ' onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<b>' + d.ueberfaelligeListe.length + '</b><span>überfällig</span></div>';
const namenHtml = d.abwesend.length
? '<div class="wand-namen">' + d.abwesend.map(p =>
'<span class="wand-avatar ist-klickbar" title="' + esc(p.name) + ' — Alle Abwesenheiten anzeigen" onclick="wandZeigeUebersicht(\'abwesend\')">'
+ esc(initialsFor(p.name)) + '</span>').join('') + '</div>'
: '<div class="wand-leer-klein">niemand abwesend</div>';
const inhalt = '<div class="wand-stat-reihe">' + offenStat + ueberfaelligStat + '</div>' + namenHtml;
return wandKachel('Heute', inhalt);
}
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
function wandUebersichtOffeneAufgaben(w){
const d = wandHeuteDaten(w);
if(d.offeneListe.length === 0){
return wandUebersichtRahmen('Offene Aufgaben', '<div class="wand-leer">Keine offenen Aufgaben.</div>');
}
const zeilen = d.offeneListe.map(t =>
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', t.datum ? esc(relativerTag(t.datum)) : 'ohne Termin',
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')')
).join('');
return wandUebersichtRahmen('Offene Aufgaben (' + d.offeneListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// wandUebersichtUeberfaellig: alle überfälligen Aufgaben, am längsten
// überfällig zuerst — jede Zeile springt ins Projekt, Abschnitt "Aufgaben".
// Parameter: w. Rückgabe: HTML-String.
function wandUebersichtUeberfaellig(w){
const d = wandHeuteDaten(w);
if(d.ueberfaelligeListe.length === 0){
return wandUebersichtRahmen('Überfällige Aufgaben', '<div class="wand-leer">Keine überfälligen Aufgaben.</div>');
}
const zeilen = d.ueberfaelligeListe.map(t =>
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', esc(relativerTag(t.datum)),
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')', 'ist-rot')
).join('');
return wandUebersichtRahmen('Überfällige Aufgaben (' + d.ueberfaelligeListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// wandUebersichtAbwesend: alle heute abwesenden Personen mit Art und bis
// wann — nicht anklickbar (eine Abwesenheit gehört zu keinem Projekt, in das
// man springen könnte). Parameter: w. Rückgabe: HTML-String.
function wandUebersichtAbwesend(w){
const d = wandHeuteDaten(w);
if(d.abwesend.length === 0){
return wandUebersichtRahmen('Abwesend heute', '<div class="wand-leer">Niemand abwesend.</div>');
}
const zeilen = d.abwesend.map(p =>
wandUebersichtZeile(esc(p.name), esc(p.typ), p.bis ? 'bis ' + esc(formatWeekDate(p.bis)) : '', null)
).join('');
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Ereignis-Feed (rechte Spalte) ----------
// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der
// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch
// überfällige Aufgaben und anstehende Meilensteine — als EINE Liste, nach
// zeitlicher Nähe zu heute sortiert (|Tage bis Datum| aufsteigend, nicht nach
// Datum selbst: Ein Ereignis von gestern und eines von morgen sind beide
// "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei Monaten ist
// es nicht — unabhängig davon, ob es in der Vergangenheit oder Zukunft
// liegt). wandFeedHtml() gruppiert diese EINE Liste anschließend nach `art`
// in drei Spalten (Variante C aus dem Feed-Vergleich) — die Sortierung
// bleibt deshalb unverändert, sie gilt nur jetzt INNERHALB jeder Spalte statt
// über alle Arten hinweg. Jeder Eintrag trägt projekt/beschreibung getrennt
// (statt einem fertigen Text) sowie pid/abschnitt für den Sprung ins
// Projekt. Parameter: w. Rückgabe: Array aus {art, projekt (HTML, bereits
// escaped), beschreibung (HTML, bereits escaped), zeit, pid, abschnitt}.
function wandAktuelleEreignisse(w){
const heute = heuteIso();
const prevWeek = (typeof findPreviousWeek === 'function') ? findPreviousWeek(w) : null;
const ereignisse = [];
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
if(pr.group === 'In Anbahnung') return;
const projekt = esc(pr.nr || '') + (pr.nr ? ' ' : '') + esc(pr.name || '');
if(typeof classifyProjectChange === 'function'){
const klass = classifyProjectChange(pr, prevWeek);
if(klass.bucket === 'status' || klass.bucket === 'new'){
const vorschauRoh = pr.status ? statusPreviewText(pr.status) : '';
const vorschau = vorschauRoh.slice(0, 70);
ereignisse.push({
art: 'neu',
datum: pr.statusAt || heute,
pid: pr.id, abschnitt: 'dashboard', projekt,
beschreibung: (klass.bucket === 'new' ? 'Neues Projekt' : 'Neuer Kommentar')
+ (vorschau ? ': „' + esc(vorschau) + (vorschauRoh.length > 70 ? '…' : '') + '"' : ''),
});
}
}
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
todos.forEach(t => {
if(t.done || !t.end || t.end >= heute) return;
ereignisse.push({
art: 'ueberfaellig',
datum: t.end,
pid: pr.id, abschnitt: 'aufgaben', projekt,
beschreibung: '„' + esc(t.title || 'Aufgabe') + '" überfällig',
});
});
const meilensteine = (pr.plan && Array.isArray(pr.plan.milestones)) ? pr.plan.milestones : [];
meilensteine.forEach(m => {
if(!m.date || m.date < heute) return;
ereignisse.push({
art: 'termin',
datum: m.date,
pid: pr.id, abschnitt: 'terminplan', projekt,
beschreibung: 'Meilenstein „' + esc(m.name || 'Meilenstein') + '"',
});
});
});
ereignisse.forEach(e => { e.zeit = relativerTag(e.datum); });
ereignisse.sort((a, b) => Math.abs(tageBis(a.datum)) - Math.abs(tageBis(b.datum)));
return ereignisse;
}
// WAND_FEED_SPALTEN: Reihenfolge und Beschriftung der drei Feed-Spalten.
const WAND_FEED_SPALTEN = [
{ art: 'neu', label: 'Neu' },
{ art: 'ueberfaellig', label: 'Überfällig' },
{ art: 'termin', label: 'Termine' },
];
// wandFeedHtml: gruppiert wandAktuelleEreignisse() nach Art in drei Spalten
// (Eriks Wahl "Variante C" aus dem Feed-Vergleich) — zeigt auf einen Blick,
// wie viele Ereignisse welcher Art anstehen, statt sie chronologisch zu
// mischen. Die Spaltenüberschrift nennt die TATSÄCHLICHE Gesamtzahl dieser
// Art, auch wenn nur WAND_FEED_MAX Einträge angezeigt werden. Jeder Eintrag
// bleibt für sich antippbar und springt direkt ins zugehörige Projekt.
// Parameter: w. Rückgabe: HTML-String.
function wandFeedHtml(w){
const ereignisse = wandAktuelleEreignisse(w);
const spalten = WAND_FEED_SPALTEN.map(s => {
const alle = ereignisse.filter(e => e.art === s.art);
const gezeigt = alle.slice(0, WAND_FEED_MAX);
const eintraegeHtml = gezeigt.length
? gezeigt.map(e =>
'<div class="wand-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<b>' + e.projekt + '</b><span>' + e.beschreibung + '</span>'
+ '<span class="wand-feed-zeit">' + esc(e.zeit) + '</span>'
+ '</div>'
).join('')
: '<div class="wand-leer-klein">keine</div>';
return '<div class="wand-feed-gruppe">'
+ '<div class="wand-feed-gruppe-kopf">'
+ '<span class="wand-feed-gruppe-punkt ' + s.art + '"></span>'
+ '<span class="wand-feed-gruppe-titel">' + esc(s.label) + '</span>'
+ '<span class="wand-feed-gruppe-anzahl">' + alle.length + '</span>'
+ '</div>'
+ eintraegeHtml
+ '</div>';
}).join('');
return '<div class="wand-feed-gruppen">' + spalten + '</div>';
}
// ---------- Stand ----------
// renderWandStand: reiner Zeitstempel der letzten erfolgreichen
// Aktualisierung — das einzige Lebenszeichen, dass das unbeaufsichtigte
// Display nicht eingefroren ist. Keine Parameter. Rückgabe: HTML-String.
function renderWandStand(){
const jetzt = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return '<div class="wand-stand">Stand: ' + jetzt + ' Uhr</div>';
}