// ----------------------------- // 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". // // Aufbau: aus einem Vergleich mehrerer komplett unterschiedlicher // Gesamtentwürfe wählte Erik die "Leitstand"-Struktur (Kopf → eine // Kennzahlen-Reihe aus vier Kacheln → eine Reihe aus drei Panels), dann in // einem zweiten Durchgang die konkrete Gestaltung ("Pastell Freundlich"), // zuletzt umgefärbt in die zwei Farben des Firmenlogos (aus public/logo.ico // ausgelesen) statt einer generischen Pastellmischung — siehe die // --dash-*-Eigenschaften in styles.css. Kopf: Marke "Dashboard" + Sitzung/ // Uhrzeit + wer heute abwesend ist (renderWandDashKopfHtml()). Kennzahlen- // Reihe: Auslastung gesamt, Besprochen gesamt, offene Aufgaben, überfällig // (wandKpiReiheHtml()/wandGesamtKennzahlen()). Panel-Reihe: Auslastung je // Gewerk, Besprochen je Gewerk, Aktuell — ein chronologisch sortierter // Ereignis-Feed (wandAuslastungInhalt()/wandBesprochenInhalt()/ // wandAktuellInhalt(), gemeinsam gerahmt von wandPanel()). Die Kacheln/ // Panels selbst sind NICHT antippbar (kein Auf-/Zuklappen, 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 aufgabenVerlaufJePerson()/loadAmpelColor()/ // kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/ // schwelleRot() 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 von Kopf, // Kennzahlen- und Panel-Reihe 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 // 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. // wandImGrundzustand: ganz oben gescrollt und keine Übersicht offen — dann // gibt es nichts zurückzusetzen, und der Rücksprung-Zähler ruht (Eriks // Wunsch: "Der Rücksprung-Timer muss nur laufen, wenn die Seite nicht nach // ganz oben gescrollt ist"). Rückgabe: Boolean. function wandImGrundzustand(){ const c = document.getElementById('wandContent'); return !wandUebersicht && (!c || c.scrollTop <= 2); } function wandCountdownTick(){ const uhr = document.getElementById('wtUhr'); if(uhr) uhr.textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const el = document.getElementById('wandIdleCountdown'); if(!el) return; // Im Grundzustand ruht die Uhr: ausblenden und die volle Minute // vorhalten — sobald jemand scrollt oder eine Übersicht öffnet, beginnt // sie dort bei 60 s (das Scrollen meldet wandAktivitaetErkannt() ohnehin). if(document.body.classList.contains('wandtafel-aktiv') && wandImGrundzustand()){ clearTimeout(wandIdleTimer); wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS); wandIdleDeadline = Date.now() + WAND_IDLE_MS; el.textContent = ''; 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(){ if(wandImGrundzustand()){ wandAktivitaetErkannt(); return; } 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 Normalansicht (Kopf + Kennzahlen-Reihe + // Panel-Reihe, 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 = '
Keine Sitzung vorhanden.
'; 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'); // Entwurf "Wandtafel in Glas" (Eriks Wahl): Kopf → vier Kennzahlen → // links die Gewerke (Auslastung + Besprochen je Zeile), rechts die // nächsten fünf Arbeitstage → Hinweis auf die Projektliste darunter. container.innerHTML = '
' + wtKopfHtml(w) + wtKpiHtml(w) + '
' + wtGewerkeHtml(w) + wtTageHtml(w) + '
' + wtFussHtml(w) + '
' + wandProjektlisteHtml(w); container.scrollTop = vorherigerScroll; wtSpaltenEinpassen(); } // wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste // Kennzahlen für die Kennzahlen-Reihe oben — dieselbe Rechnung wie die // Gewerk-Aufschlüsselung darunter (wandAuslastungJeGewerk()/ // wandBesprochenDaten()), nur aufsummiert statt aufgeschlüsselt. Die // bewusste Mehrfach-Gewerke-Dopplung (ein Projekt mit mehreren Gewerken // zählt seinen vollen Beitrag bei jedem Gewerk) gilt hier automatisch mit, // da beide Quellfunktionen sie bereits je Gewerk anwenden. Parameter: w. // Rückgabe: {auslastung: Zahl|null, besprochen: Zahl|null} — null, wenn sich // die Quote mangels Kapazität bzw. Projekten nicht bilden lässt. function wandGesamtKennzahlen(w){ const ausl = wandAuslastungJeGewerk(w); let stunden = 0, kapazitaet = 0; WAND_GEWERKE.forEach(g => { stunden += ausl[g].stunden; kapazitaet += ausl[g].kapazitaet; }); const bespr = wandBesprochenDaten(w); let besprochen = 0, gesamt = 0; WAND_GEWERKE.forEach(g => { besprochen += bespr[g].besprochen; gesamt += bespr[g].gesamt; }); return { auslastung: kapazitaet > 0 ? stunden / kapazitaet : null, besprochen: gesamt > 0 ? besprochen / gesamt : null, }; } // 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 '
' + esc(g) + '
' + zeilen + '
'; }).join(''); return '
' + '

Alle Projekte

' + abschnitte + '
'; } // 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 '
' + '

' + esc(titel) + '

' + '
' + inhalt + '
'; } // 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 '
' + '' + links + '' + '' + mitte + '' + '' + rechts + '' + '
'; } // 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++; }); // Stunden dieser Woche allein aus Aufgaben mit Aufwand (load.js). const av = aufgabenVerlaufJePerson(pr); if(offeneAufwandsAufgaben(pr).length) gewerke.forEach(g => { ergebnis[g].bewertbar++; }); const projektStunden = Object.values(av).reduce((s, v) => s + (v[0] || 0), 0); 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; } // 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, '
Keine Projekte mit Stunden in diesem Gewerk.
'); } 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, '
' + zeilen + '
'); } // ---------- 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'); // Je Gewerk sein eigener Haken (kommentarVon(), js/utils.js). const offeneProjekte = relevante.filter(p => !kommentarVon(p, g).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; } // 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, '
Alle Projekte dieses Gewerks sind besprochen.
'); } 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, '
' + zeilen + '
'); } // ---------- 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 ist entfallen — "offene Aufgaben"/"überfällig" stehen jetzt // in der Kennzahlen-Reihe (wandKpiReiheHtml()), das Abwesenheits-Kürzel im // Kopf (wandDashKopfHtml()); beide lesen weiterhin aus wandHeuteDaten(). // 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', '
Keine offenen Aufgaben.
'); } 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 + ')', '
' + zeilen + '
'); } // 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', '
Keine überfälligen Aufgaben.
'); } 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 + ')', '
' + zeilen + '
'); } // 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', '
Niemand abwesend.
'); } 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 + ')', '
' + zeilen + '
'); } // ---------- Ereignis-Feed (Panel "Aktuell") ---------- // =========================================================================== // Normalansicht "Wandtafel in Glas" (Eriks Wahl aus dem Entwurf): gleicher // Hintergrund wie die App (Lichtflächen + Logo, styles.css, Block // "WANDTAFEL IN GLAS"), Schrift Outfit wie die Kalenderwoche der Login-Seite. // Jedes Element ist für sich antippbar und führt in dieselben Übersichten // bzw. Projekte wie zuvor. // =========================================================================== // wtKopfHtml: Logo + "Dashboard", Sitzung, heute Abwesende (antippbar → // Übersicht "abwesend"), große Uhr mit Datum (#wtUhr, sekündlich von // wandCountdownTick() nachgeführt). Parameter: w. Rückgabe: HTML. function wtKopfHtml(w){ const d = wandHeuteDaten(w); const jetzt = new Date(); const abw = d.abwesend.length ? '
Heute abwesend' + d.abwesend.slice(0, 6).map(p => '' + esc(initialsFor(p.name)) + '').join('') + (d.abwesend.length > 6 ? '+' + (d.abwesend.length - 6) + '' : '') + '
' : '
Heute alle da
'; return '
' + '
Dashboard
' + '' + esc(w.label) + '' + abw + '
' + esc(jetzt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })) + '' + '' + esc(jetzt.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })) + '
' + '
'; } // wtAmpel: Farbe zur Auslastungsquote — dieselben Schwellen wie überall // (schwelleGelb()/schwelleRot(), js/load/load.js). function wtAmpel(quote){ // Eine Null bekommt KEINE Ampelfarbe (dieselbe Regel wie im Auslastungs- // Tab): Grün hieße "gut ausgelastet", die Null heißt "nichts erfasst". if(quote === null || quote === undefined || quote <= 0) return 'var(--wt-tinte-3)'; return quote >= schwelleRot() ? 'var(--wt-rot)' : quote >= schwelleGelb() ? 'var(--wt-gelb)' : 'var(--wt-gruen)'; } // wtKpiHtml: Auslastung gesamt und Besprochen mit Ring, offene Aufgaben // und Überfälliges (beide antippbar wie bisher). Parameter: w. Rückgabe: HTML. function wtKpiHtml(w){ const g = wandGesamtKennzahlen(w); const heute = wandHeuteDaten(w); const bespr = wandBesprochenDaten(w); let ja = 0, n = 0; WAND_GEWERKE.forEach(x => { ja += bespr[x].besprochen; n += bespr[x].gesamt; }); const ring = (pct, farbe) => ''; const ausl = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '%'; const ueber = heute.ueberfaelligeListe.length; return '
' + '
' + ring(g.auslastung === null ? 0 : g.auslastung * 100, wtAmpel(g.auslastung)) + '
' + ausl + '
Auslastungalle Gewerke
' + '
' + ring(n ? ja / n * 100 : 0, 'var(--wt-gruen)') + '
' + ja + '/' + n + '
Besprochendiese Woche
' + '
' + '
' + heute.offen + '
Offene Aufgabenin allen Projekten
' + '
' + '
' + ueber + '
Überfällig' + (ueber ? 'Frist gerissen' : 'nichts gerissen') + '
' + '
'; } // wtGewerkeHtml: je Gewerk eine Zeile mit Auslastung (Balken + Prozent, // antippbar → Übersicht "auslastung") und Besprochen-Stand (ein Kästchen je // Projekt, antippbar → Übersicht "besprochen"). Parameter: w. Rückgabe: HTML. function wtGewerkeHtml(w){ const ausl = wandAuslastungJeGewerk(w); const bespr = wandBesprochenDaten(w); const personen = {}; (Array.isArray(w.people) ? w.people : []).forEach(p => { const g = typeof personGewerk === 'function' ? personGewerk(p) : ''; if(g) personen[g] = (personen[g] || 0) + 1; }); const zeilen = WAND_GEWERKE.map(g => { const a = ausl[g], b = bespr[g]; const quote = (a.bewertbar > 0 && a.kapazitaet > 0) ? a.stunden / a.kapazitaet : null; const farbe = wtAmpel(quote); const auslWert = quote === null ? '—' : Math.round(quote * 100) + ' %'; const kaestchen = b.gesamt ? Array.from({ length: b.gesamt }, (_, i) => '').join('') : 'keine Projekte'; return '
' + '' + esc(g) + '' + (personen[g] || 0) + ' Personen' + '
' + '
' + 'Auslastung' + '' + '' + auslWert + '
' + '
' + 'Besprochen' + '' + kaestchen + '' + '' + (b.gesamt ? b.besprochen + '/' + b.gesamt : '—') + '
' + '
'; }).join(''); return '

Gewerke Auslastung und Wochenbericht

' + zeilen + '
'; } // WT_TAGE / WT_JE_TAG: wie viele Arbeitstage die Tagesansicht zeigt und wie // viele Einträge je Tag offen dastehen (der Rest als "+ n weitere"). const WT_TAGE = 5; const WT_JE_TAG = 8; // wtTageDaten: die nächsten WT_TAGE Arbeitstage ab heute (Wochenenden und // gepflegte Feiertage übersprungen, isWorkday()) mit den Meilensteinen aller // Projekte der Sitzung. // Parameter: w. Rückgabe: Array von { iso, datum: Date, heute, eintraege }. function wtTageDaten(w){ const heute = heuteIso(); const tage = []; let d = parseDateISO(heute); if(!isWorkday(d)) { while(!isWorkday(d)) d = addDays(d, 1); } while(tage.length < WT_TAGE){ if(isWorkday(d)) tage.push({ iso: formatDateISO(d), datum: new Date(d), heute: formatDateISO(d) === heute, eintraege: [] }); d = addDays(d, 1); } const jeTag = {}; tage.forEach(t => { jeTag[t.iso] = t; }); const erster = tage[0]; // Termine an Wochenenden und Feiertagen zwischen den Spalten gehören zum // Arbeitstag DAVOR (ein Termin am Samstag ist praktisch einer am Freitag). // Ohne diese Zuordnung fiel ein solcher Eintrag aus der Tafel heraus, weil // es für sein Datum keine Spalte gibt. Der Eintrag nennt dann das // eigentliche Datum. const spalteFuer = {}; const WT = ['So','Mo','Di','Mi','Do','Fr','Sa']; for(let x = parseDateISO(erster.iso), ende = parseDateISO(tage[tage.length - 1].iso); x <= ende; x = addDays(x, 1)){ const iso = formatDateISO(x); spalteFuer[iso] = jeTag[iso] || tage.filter(t => t.iso < iso).pop() || erster; } const hinweisFuer = iso => jeTag[iso] ? '' : 'am ' + WT[parseDateISO(iso).getDay()] + ' ' + parseDateISO(iso).getDate() + '.' + (parseDateISO(iso).getMonth() + 1) + '. · '; (Array.isArray(w.projects) ? w.projects : []).forEach(pr => { const projekt = (pr.nr ? pr.nr + ' ' : '') + (pr.name || ''); const plan = pr.plan || {}; // Eriks Wunsch: In den Tagesspalten stehen NUR Meilensteine. Aufgaben // (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute // Abwesenden über die Kopfleiste. (plan.milestones || []).forEach(m => { if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date }); }); }); tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort)))); return tage; } // wtTageHtml: die Spalten der nächsten Tage, nur mit Meilensteinen (Eriks // Wunsch). Ein Eintrag springt in den Terminplan seines Projekts. // Parameter: w. Rückgabe: HTML. function wtTageHtml(w){ const tage = wtTageDaten(w); const WT = ['So','Mo','Di','Mi','Do','Fr','Sa']; const gruppe = {}; const spalten = tage.map(t => { const sichtbar = t.eintraege.slice(0, WT_JE_TAG); const rest = t.eintraege.length - sichtbar.length; let letzteGruppe = null; const eintraege = sichtbar.map(e => { let kopf = ''; if(t.heute && gruppe[e.art] && e.art !== letzteGruppe){ kopf = '
' + gruppe[e.art] + '
'; letzteGruppe = e.art; } const klick = e.pid ? ' onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + e.abschnitt + '\')"' : ''; return kopf + '
' + esc(e.titel) + '' + esc(e.projekt) + '
'; }).join(''); // "+ n weitere" steht fest am unteren Rand; wtSpaltenEinpassen() zählt // nach dem Zeichnen noch die Einträge hinzu, die nicht in die Höhe passen. const mehr = '
0 ? '' : ' hidden') + '>+ ' + rest + ' weitere
'; const titel = t.heute ? 'Heute' : WT[t.datum.getDay()]; const datum = (t.heute ? WT[t.datum.getDay()] + ' ' : '') + t.datum.getDate() + '.' + (t.datum.getMonth() + 1) + '.'; return '
' + titel + '' + datum + '
' + '
' + (eintraege || '
Kein Meilenstein.
') + '
' + mehr + '
'; }).join(''); return '

Die nächsten Tage Meilensteine aller Projekte

' + '
' + spalten + '
' + '
'; } // wtSpaltenEinpassen: blendet in jeder Tagesspalte die Einträge aus, die // nicht mehr in die Höhe passen (die Spaltenhöhe hängt vom Bildschirm ab, // zweizeilige Titel brauchen mehr Platz), und zählt sie zu "+ n weitere". // Läuft nach jedem Zeichnen der Normalansicht. Keine Parameter. function wtSpaltenEinpassen(){ document.querySelectorAll('#wandContent .wt-tag').forEach(tag => { const liste = tag.querySelector('.wt-tag-liste'); const mehr = tag.querySelector('.wt-mehr'); if(!liste || !mehr) return; let versteckt = 0; const eintraege = [...liste.querySelectorAll('.wt-ein')]; while(liste.scrollHeight > liste.clientHeight + 1 && eintraege.length){ const e = eintraege.pop(); e.hidden = true; versteckt++; // Eine Gruppenüberschrift ohne sichtbaren Eintrag danach mit ausblenden. const vor = e.previousElementSibling; if(vor && vor.classList.contains('wt-gruppe') && (!vor.nextElementSibling || vor.nextElementSibling.hidden)) vor.hidden = true; } const rest = (Number(mehr.dataset.rest) || 0) + versteckt; mehr.textContent = '+ ' + rest + ' weitere'; mehr.hidden = rest === 0; }); } // wtFussHtml: Hinweis auf die Projektliste darunter (zum Wischen). function wtFussHtml(w){ const n = (w.projects || []).length; return '
↓Wischen für alle ' + n + ' Projekte
'; }