From 87fc43db87fae9b4401e7bedc55cc894588efadd Mon Sep 17 00:00:00 2001 From: samuelbiasgit Date: Thu, 3 Sep 2026 08:19:00 +0200 Subject: [PATCH 1/2] dashboard - first draft of complete redesign --- .../contentpanel/insertion/insert.php | 7 +- .../insertion/util/announcements.php | 13 + .../insertion/util/contentpanel_helpers.php | 5 +- .../projects/dashboard/config.php | 21 + .../projects/dashboard/dashboard.js | 635 ++++++++++++------ .../projects/dashboard/dashboard.php | 164 ++--- web/intranet_repo/projects/dashboard/data.php | 6 + .../projects/dashboard/stylo.css | 446 +++--------- .../dashboard/util/dashboard_helpers.php | 253 ++++--- .../tests/test_web_preview.py | 19 +- .../tests/web_fixture.php | 6 +- 11 files changed, 759 insertions(+), 816 deletions(-) create mode 100644 web/intranet_repo/projects/contentpanel/insertion/util/announcements.php create mode 100644 web/intranet_repo/projects/dashboard/config.php diff --git a/web/intranet_repo/projects/contentpanel/insertion/insert.php b/web/intranet_repo/projects/contentpanel/insertion/insert.php index 84b23f1..b73efa5 100644 --- a/web/intranet_repo/projects/contentpanel/insertion/insert.php +++ b/web/intranet_repo/projects/contentpanel/insertion/insert.php @@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) { exit(); } -contentpanel_cleanup_expired_news(); +$today = date('Y-m-d'); +contentpanel_cleanup_expired_news(); +contentpanel_cleanup_expired_announcements($pdo, $today); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = trim((string) ($_POST['action'] ?? '')); @@ -52,8 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $flash = $_SESSION['contentpanel_flash'] ?? null; unset($_SESSION['contentpanel_flash']); -$today = date('Y-m-d'); -$services = contentpanel_fetch_services($pdo, $today); +$services = contentpanel_fetch_services($pdo, $today); $announcements = contentpanel_fetch_announcements($pdo, $today); $newsItems = contentpanel_list_news(); $pdo = null; diff --git a/web/intranet_repo/projects/contentpanel/insertion/util/announcements.php b/web/intranet_repo/projects/contentpanel/insertion/util/announcements.php new file mode 100644 index 0000000..bc2747d --- /dev/null +++ b/web/intranet_repo/projects/contentpanel/insertion/util/announcements.php @@ -0,0 +1,13 @@ +prepare( + 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?' + ); + $statement->execute([$today]); +} diff --git a/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php b/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php index 67b9355..86bc235 100644 --- a/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php +++ b/web/intranet_repo/projects/contentpanel/insertion/util/contentpanel_helpers.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/news.php'; require_once __DIR__ . '/news_queue.php'; +require_once __DIR__ . '/announcements.php'; function contentpanel_html($value): string { @@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array return contentpanel_query($pdo, 'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date, ansage_datum_bis AS end_date, ansage_uhrzeit AS time, ansage_ort AS location - FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von', - [$today, $today])->fetchAll(PDO::FETCH_ASSOC); + FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von', + [$today])->fetchAll(PDO::FETCH_ASSOC); } diff --git a/web/intranet_repo/projects/dashboard/config.php b/web/intranet_repo/projects/dashboard/config.php new file mode 100644 index 0000000..54e2c80 --- /dev/null +++ b/web/intranet_repo/projects/dashboard/config.php @@ -0,0 +1,21 @@ + 39.61298125, + 'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875, + 'POSTER_MAX_VISIBLE_PAGES' => 12, + 'POSTER_MIN_DWELL_TIME_S' => 12, + 'ANNOUNCEMENT_MIN_FONT_SIZE_PX' => 20, + 'ANNOUNCEMENT_META_MIN_FONT_SIZE_PX' => 15, + 'BIBLE_FONT_SIZE_PX' => 23, + 'ANNOUNCEMENT_LINE_HEIGHT' => 1.35, + 'ANNOUNCEMENT_MAX_LINE_LENGTH_CH' => 85, + 'CONTENT_MIN_CONTRAST_RATIO' => 4.5, + // Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen. + 'EMPTY_REFRESH_MS' => 30000, + 'REQUEST_TIMEOUT_MS' => 8000, +]; diff --git a/web/intranet_repo/projects/dashboard/dashboard.js b/web/intranet_repo/projects/dashboard/dashboard.js index ce79d58..2ab82e8 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.js +++ b/web/intranet_repo/projects/dashboard/dashboard.js @@ -1,236 +1,435 @@ (() => { 'use strict'; - const CONTENT_REFRESH_MS = 30_000; - const INDOOR_REFRESH_MS = 10_000; - const OUTDOOR_REFRESH_MS = 30_000; - const VERTICAL_SPEED_PX_PER_SECOND = 7; - const HORIZONTAL_SPEED_PX_PER_SECOND = 10; + const config = JSON.parse(document.getElementById('dashboard-config').textContent); + const root = document.body; + for (const [property, value] of Object.entries({ + '--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))', + '--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))', + '--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT, + '--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))', + '--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch', + })) root.style.setProperty(property, value); - const WEATHER_ICONS = { - cloudy: ``, - sun: ``, - moon: ``, - cloudy_day: ``, - cloudy_night: ``, - rain_shower: ``, - rain: ``, - thunder: ``, - snowy: ``, - mist: ``, - }; + function node(tag, className, text) { + const element = document.createElement(tag); + element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } - function setText(selector, value) { - const element = document.querySelector(selector); - if (element) { - element.textContent = value ?? ''; + function empty(stage, text) { + stage.replaceChildren(node('p', 'empty-state', text)); + } + + // Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen. + async function request(url, read, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + if (!response.ok) throw new Error('HTTP ' + response.status); + return await read(response); + } finally { + clearTimeout(timeout); } } - function updateClock() { + const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0); + const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/'); + + const dayFormatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', + }); + function berlinDay(date = new Date()) { + const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value])); + return parts.year + '-' + parts.month + '-' + parts.day; + } + let currentDay = berlinDay(); + + function groupDocuments(pages) { + const entries = new Map(); + for (const item of pages) { + const key = item.document ?? item.url; + if (!entries.has(key)) entries.set(key, []); + entries.get(key).push(item); + } + return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page)); + } + + class ImageGallery { + constructor(id, message, rotate = false) { + Object.assign(this, { + message, rotate, items: [], current: [], images: new Map(), + index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false, + }); + this.panel = document.getElementById(id); + this.stage = this.panel.querySelector('.image-stage'); + this.progress = this.panel.querySelector('.poster-progress'); + new ResizeObserver(() => this.resize()).observe(this.stage); + } + + layout() { + // Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten. + const bounds = this.stage.getBoundingClientRect(); + const style = getComputedStyle(this.stage); + const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); + const gap = this.rotate ? parseFloat(style.columnGap) : 0; + const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0; + if (!this.rotate) return { groups: [this.items], width, height, gap, frame }; + const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100 + - config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX; + const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap); + const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES + && occupied(pages) <= width + gap; + // Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen. + const chunks = []; + for (const pages of groupDocuments(this.items)) { + let chunk = []; + for (const item of pages) { + if (chunk.length && !fits([...chunk, item])) { + chunks.push(chunk); + chunk = []; + } + chunk.push(item); + } + if (chunk.length) chunks.push(chunk); + } + // Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig. + chunks.sort((a, b) => occupied(b) - occupied(a)); + const groups = []; + for (const chunk of chunks) { + const group = groups.find(group => fits([...group, ...chunk])); + if (group) group.push(...chunk); + else groups.push([...chunk]); + } + // Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen, + // verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen. + const primaryGroups = groups.map(group => [...group]); + for (let index = 0; groups.length > 1 && index < groups.length; index++) { + const current = groups[index]; + const previous = primaryGroups[(index + groups.length - 1) % groups.length]; + const available = previous.filter(item => + !current.some(entry => entry.url === item.url)); + for (const pages of groupDocuments(available).reverse()) { + if (fits([...current, ...pages])) current.unshift(...pages); + } + for (const item of [...available].reverse()) { + if (!current.some(entry => entry.url === item.url) + && fits([...current, item])) current.unshift(item); + } + groups[index] = groupDocuments(current).flat(); + } + return { groups, width, height, gap, frame }; + } + + loadImage(item) { + if (!this.images.has(item.url)) { + let localUrl; + this.images.set(item.url, request(item.url, async response => { + localUrl = URL.createObjectURL(await response.blob()); + const image = new Image(); + image.alt = item.alt; + image.dataset.source = item.url; + image.src = localUrl; + await image.decode(); + return image; + }).catch(() => { + if (localUrl) URL.revokeObjectURL(localUrl); + this.images.delete(item.url); + return null; + })); + } + return this.images.get(item.url); + } + + pruneImages() { + for (const [url, pending] of this.images) { + if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue; + this.images.delete(url); + pending.then(image => { if (image) URL.revokeObjectURL(image.src); }); + } + } + + update(items, message = this.message) { + const signature = JSON.stringify([items, message]); + if (signature === this.signature && !this.failed) return; + this.signature = signature; + this.message = message; + this.items = items; + // Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden. + for (const item of items) this.loadImage(item); + if (!items.length) { + this.generation++; + clearTimeout(this.timer); + this.current = []; + this.pending = false; + empty(this.stage, message); + this.pruneImages(); + this.schedule(); + return; + } + // Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene + // Bilder bis zum vollständig geladenen Ersatz sichtbar. + if (this.rotate && this.current.some(item => item.expires < currentDay)) { + this.current = []; + empty(this.stage, this.message); + } + if (this.rotate && this.current.length) { + this.pending = true; + return; + } + return this.show(0); + } + + resize() { + if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index); + } + + schedule() { + clearTimeout(this.timer); + if (!this.rotate) return; + const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS; + this.progressAnimation?.cancel(); + this.progress.toggleAttribute('hidden', !this.items.length); + if (this.items.length) { + this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate( + [{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }], + { duration, easing: 'linear', fill: 'forwards' }); + } + this.timer = setTimeout(async () => { + if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) { + return this.show(this.index + 1); + } + // Texte und Bildbestand nach einem vollständigen Durchlauf prüfen. + await this.onCycle?.(); + if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0); + else this.schedule(); + }, duration); + } + + async show(index) { + if (!this.items.length) return; + this.pending = false; + const layout = this.layout(); + this.layoutKey = layoutKey(layout); + this.index = index % layout.groups.length; + const batch = layout.groups[this.index]; + const documents = this.rotate ? groupDocuments(batch) : [batch]; + const renderHeight = Math.min(layout.height - 2 * layout.frame, + (layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch)); + const generation = ++this.generation; + clearTimeout(this.timer); + const sheet = node('div', 'image-sheet'); + let failures = 0; + // Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden. + await Promise.all(documents.flatMap(pages => { + const wrapper = this.rotate ? node('div', 'poster-document') : sheet; + if (this.rotate) sheet.append(wrapper); + return pages.map(async item => { + const cell = node('div', 'image-cell'); + cell.style.height = renderHeight + 'px'; + cell.style.width = (renderHeight * item.width / item.height) + 'px'; + wrapper.append(cell); + const copy = (await this.loadImage(item))?.cloneNode(); + if (copy) { + try { + await copy.decode(); + cell.append(copy); + return; + } catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ } + } + failures++; + empty(cell, 'Seite zurzeit nicht verfügbar.'); + }); + })); + if (generation !== this.generation) return; + if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0); + this.failed = failures > 0; + // Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden. + if (!this.rotate && failures && this.stage.querySelector('img')) return; + this.current = batch; + this.stage.replaceChildren(sheet); + this.pruneImages(); + if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.'); + this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe. + } + } + + // Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank. + function paragraphs(className, entries) { + const container = node('div', className); + for (const [name, text] of entries) if (text) container.append(node('p', name, text)); + return container; + } + + function announcementList(items) { + const list = node('ul', 'text-page announcement-list'); + for (const item of items) { + const entry = node('li', 'announcement-entry'); + entry.append(paragraphs('announcement-when', [ + ['announcement-date', item.date_label || 'Ohne Datum'], + ['announcement-end', item.end_label], + ]), paragraphs('announcement-content', [ + ['announcement-title', item.title], + ['announcement-subtitle', item.subtitle], + ['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')], + ])); + list.append(entry); + } + return list; + } + + class FixedText { + constructor(id, render = entries => paragraphs('text-page', entries)) { + this.stage = document.getElementById(id).querySelector('.text-stage'); + this.render = render; + this.signature = ''; + this.loaded = false; + new ResizeObserver(() => this.rebuild()).observe(this.stage); + } + + update(documents, message) { + this.loaded = true; + const signature = JSON.stringify([documents, message]); + if (signature === this.signature) return; + this.signature = signature; + if (documents.length) this.stage.replaceChildren(this.render(documents)); + else empty(this.stage, message); + this.rebuild(); + } + + unavailable(message) { + // Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen. + if (this.loaded) return; + empty(this.stage, message); + this.rebuild(); + } + + rebuild() { + const page = this.stage.querySelector('.text-page'); + this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight + && page.getBoundingClientRect().height > this.stage.clientHeight + 1)); + } + } + + const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.'); + const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true); + const announcements = new FixedText('announcements-panel', announcementList); + const bible = new FixedText('bible-panel'); + let imageData = null; + + function layoutImages() { + if (!imageData) return; + // Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation. + const months = imageData.month_images; + if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months)); + return Promise.all([ + month.update(months), + posters.update(imageData.posters.filter(item => item.expires >= currentDay)), + ]); + } + + function contentLoaded(data) { + if (data.date !== currentDay) return contentUnavailable(); + if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.'); + else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.'); + + const service = data.service; + const blocks = service ? [ + ['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'], + ['bible-verse', service.text || 'Bibelvers nicht eingetragen.'], + ] : []; + if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.'); + else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.'); + } + + function contentUnavailable() { + announcements.unavailable('Ansagen zurzeit nicht verfügbar.'); + bible.unavailable('Bibelwort zurzeit nicht verfügbar.'); + } + + function imagesUnavailable() { + if (!imageData) { + month.update([], 'Monatsplan zurzeit nicht verfügbar.'); + posters.update([], 'Aushänge zurzeit nicht verfügbar.'); + } + } + + // Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen. + function createRefresh(resource, onData, onError) { + let inFlight = null; + return () => inFlight ??= request('data.php?resource=' + resource, + async response => onData(await response.json()), { cache: 'no-store' }) + .catch(onError).finally(() => { inFlight = null; }); + } + + const timeFormat = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', + }); + const dateFormat = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric', + }); + function tick() { const now = new Date(); - const clock = document.getElementById('uhrzeit'); - if (clock) { - clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; + const nextDay = berlinDay(now); + document.getElementById('clock').textContent = timeFormat.format(now); + document.getElementById('clock').dateTime = now.toISOString(); + document.getElementById('date').textContent = dateFormat.format(now); + document.getElementById('date').dateTime = nextDay; + if (nextDay !== currentDay) { + currentDay = nextDay; + // Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene + // Aushänge weiterhin sofort entfernen. + layoutImages(); + refreshAll(); } } - function renderAnnouncements(announcements) { - const container = document.querySelector('.news-scroll'); - if (!container || !Array.isArray(announcements)) { - return; - } - - container.replaceChildren(); - announcements.forEach((announcement) => { - const box = document.createElement('div'); - box.className = 'news-box'; - - const date = document.createElement('div'); - date.className = 'news-date'; - date.append(document.createTextNode(announcement.date_label || '')); - if (announcement.end_label) { - date.append(document.createElement('br'), document.createTextNode(announcement.end_label)); - } - - const body = document.createElement('div'); - body.append(document.createTextNode(announcement.title || '')); - - if (announcement.subtitle) { - const subtitle = document.createElement('div'); - subtitle.className = 'news-subtitle'; - subtitle.textContent = announcement.subtitle; - body.append(subtitle); - } - - if (announcement.meta) { - const meta = document.createElement('div'); - meta.className = 'news-subtitle'; - const italic = document.createElement('i'); - italic.textContent = announcement.meta; - meta.append(italic); - body.append(meta); - } - - box.append(date, body); - container.append(box); - }); + const refreshContent = createRefresh('content', contentLoaded, contentUnavailable); + const refreshImages = createRefresh('images', data => { + imageData = data; + return layoutImages(); + }, imagesUnavailable); + function refreshAll() { + return Promise.all([refreshContent(), refreshImages()]); } - - function renderPosters(posters) { - const container = document.querySelector('.aushang-flex'); - if (!container || !Array.isArray(posters)) { - return; + posters.onCycle = refreshAll; + refreshAll(); + tick(); + setInterval(tick, 1000); + document.fonts.ready.then(() => { + announcements.rebuild(); + bible.rebuild(); + // Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft. + const style = getComputedStyle(root); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d', { willReadFrequently: true }); + function luminance(value) { + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3); + return channels.map(channel => { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0); } - - container.replaceChildren(); - if (posters.length === 0) { - const empty = document.createElement('p'); - empty.style.textAlign = 'left'; - empty.textContent = 'Keine Aushänge vorgeplant.'; - container.append(empty); - return; + const background = Math.min(...Array.from(document.querySelectorAll( + '.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'), + panel => luminance(getComputedStyle(panel).backgroundColor))); + for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) { + const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05); + if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)'); } + }); - posters.forEach((poster) => { - const image = document.createElement('img'); - image.src = poster.url; - image.alt = poster.alt || 'Aushang'; - image.style.height = '95%'; - image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true }); - container.append(image); - }); - } - - function renderMonthImages(images) { - if (!Array.isArray(images)) { - return; + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + tick(); + // Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit. + posters.schedule(); } - - images.forEach((image) => { - const element = document.querySelector(`[data-month-page="${image.page}"]`); - if (element && image.url && element.getAttribute('src') !== image.url) { - element.src = image.url; - } - }); - } - - function renderIndoor(indoor) { - if (indoor) { - setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`); - } - } - - function renderOutdoor(outdoor) { - const weather = document.getElementById('wetter'); - if (!weather || !outdoor) { - return; - } - - const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy; - weather.replaceChildren(); - - const iconContainer = document.createElement('div'); - iconContainer.innerHTML = icon; - const temperature = document.createElement('div'); - temperature.className = 'text-outdoor'; - temperature.textContent = `${outdoor.temperature}°C`; - weather.append(iconContainer, temperature); - } - - function renderContent(data) { - setText('[data-dashboard-date]', data.date_label); - if (data.service) { - setText('.bible', data.service.bibelwort); - setText('.verse', data.service.bibelvers); - setText('.GB', data.service.eingang_nummer); - setText('.GB-title', data.service.eingang_titel); - } - - renderAnnouncements(data.announcements); - renderPosters(data.posters); - renderMonthImages(data.month_images); - scheduleScrollingSetup(); - } - - function startPolling(resource, interval, render) { - const refresh = async () => { - try { - const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const data = await response.json(); - if (data.error) { - throw new Error(data.error); - } - render(data); - } catch (error) { - console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error); - } finally { - window.setTimeout(refresh, interval); - } - }; - - refresh(); - } - - function applyAnimation(element, distance, speed, animationName) { - element.style.animation = 'none'; - if (distance <= 0) { - return; - } - - const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v'; - element.style.setProperty(variable, `-${distance}px`); - element.style.animationName = animationName; - element.style.animationDuration = `${Math.max(1, distance / speed)}s`; - element.style.animationTimingFunction = 'linear'; - element.style.animationIterationCount = 'infinite'; - } - - function setupScrolling() { - const posterContainer = document.querySelector('.aushang-flex'); - if (posterContainer) { - const images = Array.from(posterContainer.querySelectorAll('img')); - const totalWidth = images.reduce((sum, image) => { - const styles = window.getComputedStyle(image); - return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0); - }, 0); - applyAnimation( - posterContainer, - Math.max(0, totalWidth - posterContainer.offsetWidth), - HORIZONTAL_SPEED_PX_PER_SECOND, - 'scroll-h' - ); - } - - const newsContainer = document.querySelector('.news-container'); - const news = document.querySelector('.news-scroll'); - if (newsContainer && news) { - applyAnimation( - news, - Math.max(0, news.scrollHeight - newsContainer.offsetHeight), - VERTICAL_SPEED_PX_PER_SECOND, - 'scroll-v' - ); - } - } - - let scrollingTimer = null; - function scheduleScrollingSetup(delay = 80) { - if (scrollingTimer !== null) { - window.clearTimeout(scrollingTimer); - } - scrollingTimer = window.setTimeout(setupScrolling, delay); - } - - updateClock(); - window.setInterval(updateClock, 1000); - window.addEventListener('load', () => scheduleScrollingSetup(50)); - window.addEventListener('resize', () => scheduleScrollingSetup(100)); - startPolling('content', CONTENT_REFRESH_MS, renderContent); - startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor)); - startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor)); + }); })(); diff --git a/web/intranet_repo/projects/dashboard/dashboard.php b/web/intranet_repo/projects/dashboard/dashboard.php index 993ea0a..002fa09 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.php +++ b/web/intranet_repo/projects/dashboard/dashboard.php @@ -2,139 +2,65 @@ declare(strict_types=1); -date_default_timezone_set('Europe/Berlin'); -require_once __DIR__ . '/util/dashboard_helpers.php'; - -$pdo = require __DIR__ . '/../../general_files/ini/database.php'; -$content = dashboard_fetch_content($pdo, dashboard_now()); -$pdo = null; - -$service = $content['service']; -$announcements = $content['announcements']; -$posters = $content['posters']; -$monthImages = $content['month_images']; -$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css'); -$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js'); +// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können. +$config = require __DIR__ . '/config.php'; ?> - NAK Reinickendorf - - - - - + + Gemeindeinformationen · NAK Berlin-Reinickendorf + + + + + - -
-
-
-
-
-
-
-
+ +
+
+

Ansagen

+

Ansagen werden geladen …

+
-
-
Heutige Ansagen
-
-
- -
-
- - -
- -
-
- - -
- - -
- -
-
- -
-
+
+
+
+ Neuapostolische Kirche +
+ Neuapostolische Kirche + Berlin-Reinickendorf
- -
-
-
Bibelwort
-
-
-
- -
-
-
Eingangslied
-
-
-
- -
-
Temperatur
-
-
-
- -
-
-
-
-
+
+ +
-
- -
-
Aushänge
-
- -

Keine Aushänge vorgeplant.

- - - <?= dashboard_html($poster['alt']) ?> - - -
-
+
+
+

Bibelwort für heute

+

Bibelwort wird geladen …

+
-
-
-
-
+
+

Monatsplan

+

Monatsplan wird geladen …

+
-
-
Monatsplan
-
- - Monatsplan Seite <?= (int) $image['page'] ?> - -
+
+
+

Aushänge

+
-
-
+

Aushänge werden geladen …

+ +
+ diff --git a/web/intranet_repo/projects/dashboard/data.php b/web/intranet_repo/projects/dashboard/data.php index 8900e2f..a588d56 100644 --- a/web/intranet_repo/projects/dashboard/data.php +++ b/web/intranet_repo/projects/dashboard/data.php @@ -17,6 +17,12 @@ try { $pdo = require __DIR__ . '/../../general_files/ini/database.php'; $payload = dashboard_fetch_content($pdo, dashboard_now()); break; + case 'images': + $payload = [ + 'posters' => dashboard_list_posters(dashboard_now()), + 'month_images' => dashboard_month_images(), + ]; + break; case 'indoor': $payload = ['indoor' => dashboard_fetch_indoor_temperature()]; break; diff --git a/web/intranet_repo/projects/dashboard/stylo.css b/web/intranet_repo/projects/dashboard/stylo.css index af53312..b94ea95 100644 --- a/web/intranet_repo/projects/dashboard/stylo.css +++ b/web/intranet_repo/projects/dashboard/stylo.css @@ -1,340 +1,106 @@ -/* Gesamtes Seiten-Layout */ -body { - margin: 0; - height: 100vh; - display: flex; - justify-content: center; - align-items: center; - font-family: 'SUSE', sans-serif; - font-size: 20px; - background-image: url('cloudy.jpg'); - background-size: cover; - background-repeat: no-repeat; - background-position: center center; - overflow: hidden; -} - -/* Content Board Layout */ -.content-board { - background-color: #a8a6a7a7; - width: 99%; - height: 97%; - box-sizing: border-box; - display: flex; - justify-content: space-around; - align-items: center; - border-radius: 0; - padding: 0 6px 7px 3px; -} - -.content-column-left { - width: 40%; - height: 100%; - box-sizing: border-box; -} - -.content-column-right { - width: 58%; - height: 100%; - box-sizing: border-box; -} - -.temperature { - display: flex; - justify-content: space-around; - align-items: center; - padding: 5px 0 0 0; - height: 70%; -} - -.verse { - text-align: justify; - color: rgb(67, 67, 67); - padding: 5px 15px 5px 15px; -} - -.date { - color: rgb(67, 67, 67); - font-weight: bold; -} - -.content-column-right-upper-box-left { - width: 60%; - height: 100%; - box-sizing: border-box; -} - -.content-column-right-upper-box-right { - width: 39%; - height: 100%; - box-sizing: border-box; -} - -.content-column-right-upper-box { - width: 100%; - height: 51%; - box-sizing: border-box; - display: flex; - justify-content: space-between; - align-items: center; -} - -.content-item-time { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #ffffffab; - width: 100%; - height: 30%; - margin: 15px 0 15px 0; - box-sizing: border-box; -} - -.content-item-news { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #ffffffab; - width: 100%; - height: 63%; - margin: 15px 0 15px 0; - box-sizing: border-box; -} - -.content-item-video { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #cbc5bc; - width: 100%; - height: 40%; - margin: 15px 0 15px 0; - box-sizing: border-box; -} - -.content-item-middle-top { - position: relative; - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #cbc5bc; - width: 100%; - height: 60%; - margin: 15px 0 15px 0; - box-sizing: border-box; - overflow: hidden; -} - -/* Pseudoelement für den animierten border-bottom Effekt */ -.content-item-middle-top::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 15px; - background: linear-gradient(to bottom, transparent, #cbc5bc); -} - -/* Monatsplan: jetzt mit weißem Hintergrund */ -.content-item-month { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #cbc5bc7d; - width: 100%; - height: 56%; - margin: 15px 0 15px 0; - box-sizing: border-box; - overflow: hidden; -} -.content-item-month-cell { - display: flex; - background-color: #ffffffe5; - justify-content: center; /* Zentriere die Bilder horizontal */ - height: 94%; - align-items: center; /* Zentriere die Bilder vertikal */ -} - -.content-item-month-cell img { - max-width: 100%; /* Verhindert, dass das Bild größer als der Container wird */ - max-height: 100%; /* Verhindert Verzerrungen, indem es die maximale Höhe auf die Containerhöhe beschränkt */ - object-fit: contain; /* Stellt sicher, dass das Bild im Container bleibt, ohne gestreckt zu werden */ -} - -.content-item-daily { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #edebe8; - width: 100%; - height: 63%; - margin: 15px 0 15px 0; - box-sizing: border-box; - overflow: hidden; -} - -.header-sticky-note { - font-size: 25px; - display: flex; - flex-direction: column; - align-items: center; - height: 20px; - width: 100%; - padding: 5px 0 15px 0; - border-bottom: 2px solid #38312430; - background-color: #cbc5bc; - border-radius: 0; -} - -.time { - font-size: 80px; - text-align: center; - padding: 8px 0 0 0; -} - -.bible { - text-align: center; - font-size: 60px; - padding: 10px 0 10px 0; - border-bottom: 2px solid #38312456; - background-color: #edebe8; -} - -.GB { - text-align: center; - font-size: 60px; - padding: 5px 0 0 0; -} - -.GB-title { - text-align: center; - color: rgb(67, 67, 67); -} - -.content-column-double-item { - width: 100%; - height: 33%; - margin: 15px 0 15px 0; - box-sizing: border-box; - display: flex; - justify-content: space-between; - align-items: center; -} - -/* Elemente innerhalb des Content Boards */ -.content-item { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #edebe8; - height: 100%; - width: 49%; -} - -.content-item-bottom-box { - border-radius: 0; - box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16); - background-color: #edebe8; - margin: 6px 0 0 0; - height: 47.5%; - width: 100%; - overflow: hidden; -} - -.video-container { - border-radius: 0; - width: 100%; - height: 100%; - overflow: hidden; - position: relative; - border: 5px solid #cbc5bc; - box-sizing: border-box; - display: flex; - justify-content: center; - align-items: center; -} - -.video-container video { - width: 100%; - height: 100%; - transform: scale(1.2); - object-fit: cover; - border-radius: 0; -} - -.container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 40%; - background-color: #38312431; - border-radius: 0; - border: 2px solid #38312440; -} - -.text-outdoor { - font-weight: bold; - padding: 9px 0 0 0; - margin: 0 0 15px 0; -} - -.text-indoor { - font-weight: bold; - margin: 0 0 15px 0; -} - -.news-box { - display: flex; - justify-content: flex-start; - padding: 5px 15px 5px 15px; - border-bottom: 2px solid #38312456; - background-color: #edebe8; - word-wrap: break-word; -} - -.news-date { - font-weight: bold; - margin: 0 10px 0 0; -} - -.news-subtitle { - color: rgba(67, 67, 67, 0.657); -} - -.news-container { - height: 290px; - overflow: hidden; - position: relative; -} -.news-scroll { - display: flex; - flex-direction: column; - animation-name: scroll-v; - animation-timing-function: linear; - animation-iteration-count: infinite; - --scroll-distance-v: 0px; -} -@keyframes scroll-v { - 0% { transform: translateY(0); } - 100% { transform: translateY(var(--scroll-distance-v)); } -} - -.aushang-flex img { - border-radius: 0; - margin: 0 10px 0 0; - border: 2px solid #38312440; - box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.08); -} - -.aushang-flex { - padding: 0 5px 0 5px; - display: flex; - justify-content: space-evenly; - align-items: center; - height: 90.5%; - border-radius: 0; - - animation-name: scroll-h; - animation-timing-function: linear; - animation-iteration-count: infinite; - --scroll-distance-h: 0px; -} -@keyframes scroll-h { - 0% { transform: translateX(0); } - 100% { transform: translateX(var(--scroll-distance-h)); } -} +/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */ +body.dashboard { + --dashboard-text: var(--nak-black); + --dashboard-meta: var(--gray-700); + --panel-surface: #fff; + --dashboard-unit: clamp(1px, 0.052083333vw, 2px); + --panel-heading-height: calc(31 * var(--dashboard-unit)); + /* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */ + --original-board-height: calc(97vh - 7px); + --month-image-height: calc(var(--original-board-height) * 0.56 * 0.94); + --month-aspect: 1.414; + --announcement-font: var(--bible-font); + --announcement-detail-font: calc(20 * var(--dashboard-unit)); + --announcement-meta-font: calc(15 * var(--dashboard-unit)); + --announcement-leading: 1.35; + --announcement-measure: 85ch; + --bible-font: calc(23 * var(--dashboard-unit)); + display: block; + height: 100vh; + height: 100dvh; + min-height: 0; + padding: 0; + color: var(--dashboard-text); + background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat; + overflow: hidden; +} +.dashboard [hidden] { display: none !important; } +.dashboard-main { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px); + grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height) + 2px) minmax(0, 1fr); + grid-template-areas: "community announcements month" "posters posters posters"; + gap: var(--space-sm); + height: 100%; + min-height: 0; + padding: var(--space-sm); +} +#month-panel { grid-area: month; --panel-accent: var(--nak-darkblue); } +#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; } +#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); } +.community-column { grid-area: community; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--space-sm); min-height: 0; min-width: 0; } +.dashboard-overview { background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: calc(12 * var(--dashboard-unit)); padding: calc(8 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)); } +.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); } +.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); } +.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); } +.dashboard-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; } +.dashboard-wordmark strong { font-weight: 500; } +.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); } +.dashboard-clock time { display: block; } +#clock { font-size: calc(48 * var(--dashboard-unit)); line-height: 1; font-weight: 500; font-variant-numeric: tabular-nums; } +#date { font-size: var(--announcement-meta-font); white-space: nowrap; } +.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); border: 1px solid var(--gray-300); border-radius: 0; } +.image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); } +.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); } +.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; } +.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; } +.poster-progress-track { stroke: var(--gray-300); } +.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; } +.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); } +#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); } +.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; } +#month-panel .image-sheet { gap: 0; } +.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; } +.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); } +.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; } +.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); } +.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; } +#announcements-panel .text-stage { margin: 0; } +#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); } +.text-page { max-width: var(--announcement-measure); color: var(--dashboard-text); } +.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); } +.text-page p:last-child { margin-bottom: 0; } +.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; } +.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); } +.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); } +.announcement-list { max-width: none; margin: 0; padding: 0; list-style: none; } +/* Originale Zeilenaufteilung: Datum links, Inhalt mit Zeit/Ort rechts. */ +.announcement-entry { display: grid; grid-template-columns: calc(100 * var(--dashboard-unit)) minmax(0, 1fr); gap: calc(12 * var(--dashboard-unit)); padding: calc(6 * var(--dashboard-unit)) calc(10 * var(--dashboard-unit)); border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); } +.announcement-entry:nth-child(even) { background: var(--gray-50); } +.announcement-content { min-width: 0; } +.announcement-entry p { margin: 0; } +.announcement-content .announcement-meta { font-style: italic; } +.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; } +.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); } +.bible-panel { --panel-accent: var(--nak-yellow); } +.bible-panel .text-page { max-width: none; } +.text-stage.is-overflowing::after { + content: ""; + position: absolute; + inset: auto 0 0; + height: calc(24 * var(--dashboard-unit)); + background: linear-gradient(to bottom, transparent, var(--panel-surface)); + pointer-events: none; +} +.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); } +.image-stage > .empty-state { padding: var(--space-md); } +.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); } +@media (max-width: 999px), (max-height: 599px) { + body.dashboard { height: auto; min-height: 100vh; overflow: visible; } + .dashboard-main { + height: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "community" "announcements" "month" "posters"; + grid-template-rows: 520px 320px 65vh 55vh; + } +} diff --git a/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php index 79bb7fe..254b3ba 100644 --- a/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php +++ b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php @@ -1,6 +1,9 @@ setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE)); $today = $now->format('Y-m-d'); + $payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []]; - $serviceStatement = $pdo->prepare( - 'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel - FROM gottesdienst - WHERE bibeldatum = ?' - ); - $serviceStatement->execute([$today]); - $service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: []; - - $announcementStatement = $pdo->prepare( - 'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel - FROM ansagen - WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? - ORDER BY ansage_datum_von ASC' - ); - $announcementStatement->execute([$today, $today]); - $announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC); - - foreach ($announcements as &$announcement) { - $announcement = dashboard_prepare_announcement($announcement, $now); + // Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht. + try { + $statement = $pdo->prepare( + 'SELECT bibelwort, bibelvers FROM gottesdienst + WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1' + ); + $statement->execute([$today]); + $service = $statement->fetch(PDO::FETCH_ASSOC); + if ($service) { + // Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text. + $payload['service'] = [ + 'reference' => trim((string) ($service['bibelwort'] ?? '')), + 'text' => trim((string) ($service['bibelvers'] ?? '')), + ]; + } + } catch (Throwable $exception) { + error_log('Dashboard Bibelwort: ' . $exception->getMessage()); + $payload['errors'][] = 'service'; } - unset($announcement); - contentpanel_cleanup_expired_news(); + try { + $statement = $pdo->prepare( + 'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, + ansage_titel, ansage_untertitel + FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? + ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC' + ); + contentpanel_cleanup_expired_announcements($pdo, $today); + $statement->execute([$today]); + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $payload['announcements'][] = dashboard_prepare_announcement($row, $now); + } + } catch (Throwable $exception) { + error_log('Dashboard Ansagen: ' . $exception->getMessage()); + $payload['errors'][] = 'announcements'; + } - return [ - 'date_label' => dashboard_date_label($now), - 'service' => [ - 'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'), - 'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'), - 'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'), - 'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'), - ], - 'announcements' => $announcements, - 'posters' => dashboard_list_posters(), - 'month_images' => dashboard_month_images(), - ]; + return $payload; } -function dashboard_date_label(DateTimeImmutable $date): string +function dashboard_date(string $raw): ?DateTimeImmutable { - static $months = [ - 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', - 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', - ]; - - return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE)); + return $date && $date->format('Y-m-d') === $raw ? $date : null; } function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array { - $startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10); - $endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10); - $timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5); - - $startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone()); - $tomorrow = $now->setTime(0, 0)->modify('+1 day'); - - if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) { - $dateLabel = 'Heute'; - } elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) { - $dateLabel = 'Morgen'; - } elseif ($startDate) { - static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; - $dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.'); + $now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE)); + $start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10); + $end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10); + $today = $now->format('Y-m-d'); + $startDate = dashboard_date($start); + $endDate = dashboard_date($end); + if ($start === $today) { + $label = 'Heute'; + } elseif ($start === $now->modify('+1 day')->format('Y-m-d')) { + $label = 'Morgen'; } else { - $dateLabel = $startRaw; + $days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + $label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : ''; } - $endLabel = ''; - if ($endRaw !== '') { - $endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone()); - $endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw; - } - - $meta = []; - if ($timeRaw !== '') { - $meta[] = $timeRaw . ' Uhr'; - } + $endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : ''; + $time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5); $place = trim((string) ($announcement['ansage_ort'] ?? '')); - if ($place !== '') { - $meta[] = $place; - } return [ - 'date_label' => $dateLabel, + 'start' => $start, + 'end' => $end, + 'date_label' => $label, 'end_label' => $endLabel, - 'title' => (string) ($announcement['ansage_titel'] ?? ''), - 'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''), - 'meta' => implode(', ', $meta), + 'time' => $time, + 'place' => $place, + 'title' => trim((string) ($announcement['ansage_titel'] ?? '')), + 'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')), + 'meta' => implode(' · ', array_filter([$label, $endLabel, $time !== '' ? $time . ' Uhr' : '', $place])), ]; } -function dashboard_list_posters(): array +function dashboard_image(string $path, string $url): ?array { - $files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: []; - natsort($files); - - $posters = []; - foreach ($files as $file) { - if (!is_file($file)) { - continue; - } - - $basename = basename($file); - $posters[] = [ - 'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file), - 'alt' => 'Aushang', - ]; + if (!is_file($path)) { + return null; } - - return array_values($posters); + $size = @getimagesize($path); + if (!$size || $size[0] < 1 || $size[1] < 1) { + return null; + } + // Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen. + $version = @hash_file('sha256', $path); + if ($version === false) { + return null; + } + return [ + 'url' => $url . '?v=' . $version, + 'width' => $size[0], + 'height' => $size[1], + ]; } -function dashboard_month_images(): array +function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array { + $today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d'); + $files = glob($directory . '/*.png') ?: []; + natsort($files); + $posters = []; + foreach ($files as $file) { + $name = basename($file); + if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match) + || !contentpanel_is_valid_news_file_base($match[1])) { + continue; + } + // Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen. + $expiry = substr($match[1], 0, 10); + if (!dashboard_date($expiry) || $expiry < $today) { + continue; + } + $image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name)); + if ($image) { + $title = str_replace('_', ' ', substr($match[1], 11)); + $posters[] = $image + [ + 'document' => $match[1], + 'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2], + 'title' => $title, 'alt' => $title . ' · Seite ' . (int) $match[2], + ]; + } + } + return $posters; +} + +function dashboard_month_images(?string $directory = null): array +{ + $directory ??= __DIR__ . '/../month'; $images = []; foreach ([1, 2] as $page) { $filename = 'terminuebersicht-' . $page . '.png'; - $path = __DIR__ . '/../month/' . $filename; - $version = is_file($path) ? (int) filemtime($path) : 0; - $images[] = [ - 'page' => $page, - 'url' => 'month/' . $filename . '?v=' . $version, - ]; + $image = dashboard_image($directory . '/' . $filename, 'month/' . $filename); + if ($image) { + $images[] = $image + [ + 'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan', + 'alt' => 'Monatsplan · Seite ' . $page, + ]; + } } - return $images; } @@ -159,17 +178,12 @@ function dashboard_fetch_indoor_temperature(): array $url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID); $data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]); - if (!array_key_exists('state', $data)) { + if (!isset($data['state']) || !is_numeric($data['state'])) { throw new RuntimeException('Unerwartete Home-Assistant-Antwort.'); } - $value = $data['state']; - if (is_numeric($value)) { - $value = round((float) $value, 1); - } - return [ - 'value' => $value, + 'value' => round((float) $data['state'], 1), 'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'), ]; } @@ -186,7 +200,6 @@ function dashboard_fetch_outdoor_weather(): array $data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query); $temperature = $data['main']['temp'] ?? null; - $icon = (string) ($data['weather'][0]['icon'] ?? ''); if (!is_numeric($temperature)) { throw new RuntimeException('Unerwartete OpenWeather-Antwort.'); @@ -194,27 +207,9 @@ function dashboard_fetch_outdoor_weather(): array return [ 'temperature' => (int) round((float) $temperature), - 'icon' => dashboard_weather_kind($icon), ]; } -function dashboard_weather_kind(string $icon): string -{ - return match ($icon) { - '04d', '04n', '03d', '03n' => 'cloudy', - '02d' => 'cloudy_day', - '02n' => 'cloudy_night', - '01d' => 'sun', - '01n' => 'moon', - '09d', '09n', '10n' => 'rain_shower', - '10d' => 'rain', - '11d', '11n' => 'thunder', - '13d', '13n' => 'snowy', - '50d', '50n' => 'mist', - default => 'cloudy', - }; -} - function dashboard_http_json(string $url, array $headers = []): array { $handle = curl_init($url); diff --git a/worker/contentpanel_aushang/tests/test_web_preview.py b/worker/contentpanel_aushang/tests/test_web_preview.py index 0a4bd28..2cf74cc 100644 --- a/worker/contentpanel_aushang/tests/test_web_preview.py +++ b/worker/contentpanel_aushang/tests/test_web_preview.py @@ -125,15 +125,28 @@ class WebPreviewTests(unittest.TestCase): self.page.screenshot(path=str(Path(output) / f'web-{width}.png'), full_page=True, animations='disabled') self.assertEqual(self.page.locator('script:not([src])').count(), 0) - def test_permissions_and_csrf_reject_mutations(self): + def test_permissions_and_csrf_reject_mutations(self): denied = self.page.request.post(self.url + '?deny=1', form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig'}, max_redirects=0) self.assertEqual(denied.status, 303) self.assertIn('/intranet/index.php', denied.headers['location']) self.page.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0) self.page.reload() self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text()) - self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11))) - + self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11))) + + def test_page_cleans_expired_announcements_on_each_authorized_request(self): + log = self.root / 'cleanup.jsonl' + before = len(log.read_text().splitlines()) + self.page.reload() + cleanup = [json.loads(line) for line in log.read_text().splitlines()] + self.assertEqual(len(cleanup), before + 1) + sql, params = cleanup[-1] + self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?') + self.assertEqual(len(params), 1) + self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$') + self.page.request.get(self.url + '?deny=1', max_redirects=0) + self.assertEqual(len(log.read_text().splitlines()), len(cleanup)) + def test_forms_preserve_fields_validation_and_bound_sql_parameters(self): for tab, action, fields in ( ('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}), diff --git a/worker/contentpanel_aushang/tests/web_fixture.php b/worker/contentpanel_aushang/tests/web_fixture.php index 4310052..b90ba90 100644 --- a/worker/contentpanel_aushang/tests/web_fixture.php +++ b/worker/contentpanel_aushang/tests/web_fixture.php @@ -8,8 +8,10 @@ class TestStatement extends PDOStatement public function __construct(private string $sql) {} public function execute(?array $params = null): bool { - if (!str_starts_with($this->sql, 'SELECT')) { - file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND); + if (!str_starts_with($this->sql, 'SELECT')) { + $log = str_contains($this->sql, 'WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?') + ? '/cleanup.jsonl' : '/writes.jsonl'; + file_put_contents($_SERVER['DOCUMENT_ROOT'] . $log, json_encode([$this->sql, $params]) . "\n", FILE_APPEND); } return true; } -- 2.54.0 From 01b4be301f4c34e186b6ad48e9f124aef3216081 Mon Sep 17 00:00:00 2001 From: samuelbiasgit Date: Fri, 4 Sep 2026 13:15:08 +0200 Subject: [PATCH 2/2] dashboard - whole redesign and rewrite --- .gitignore | 1 + .../projects/dashboard/config.php | 6 - .../projects/dashboard/dashboard.js | 149 +++++++++++++++--- .../projects/dashboard/dashboard.php | 33 ++-- web/intranet_repo/projects/dashboard/data.php | 7 +- .../projects/dashboard/stylo.css | 62 +++++--- .../projects/dashboard/util/bvg.php | 53 +++++++ .../dashboard/util/dashboard_helpers.php | 18 +-- worker/pdf_python/create_pdf.py | 43 ++--- 9 files changed, 273 insertions(+), 99 deletions(-) create mode 100644 web/intranet_repo/projects/dashboard/util/bvg.php diff --git a/.gitignore b/.gitignore index b44dc93..77b1182 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/* # Generierte Dashboard-Terminbilder web/intranet_repo/projects/dashboard/month/* +web/intranet_repo/projects/dashboard/cache/ # Lokale Backup-Dateien *.bak diff --git a/web/intranet_repo/projects/dashboard/config.php b/web/intranet_repo/projects/dashboard/config.php index 54e2c80..5b17abe 100644 --- a/web/intranet_repo/projects/dashboard/config.php +++ b/web/intranet_repo/projects/dashboard/config.php @@ -4,16 +4,10 @@ declare(strict_types=1); // Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt. return [ - // Original: (97vh - 7px) × 47,5% Feld × 90,5% Leiste × 95% Bildhöhe. 'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125, 'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875, 'POSTER_MAX_VISIBLE_PAGES' => 12, 'POSTER_MIN_DWELL_TIME_S' => 12, - 'ANNOUNCEMENT_MIN_FONT_SIZE_PX' => 20, - 'ANNOUNCEMENT_META_MIN_FONT_SIZE_PX' => 15, - 'BIBLE_FONT_SIZE_PX' => 23, - 'ANNOUNCEMENT_LINE_HEIGHT' => 1.35, - 'ANNOUNCEMENT_MAX_LINE_LENGTH_CH' => 85, 'CONTENT_MIN_CONTRAST_RATIO' => 4.5, // Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen. 'EMPTY_REFRESH_MS' => 30000, diff --git a/web/intranet_repo/projects/dashboard/dashboard.js b/web/intranet_repo/projects/dashboard/dashboard.js index 2ab82e8..dff5578 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.js +++ b/web/intranet_repo/projects/dashboard/dashboard.js @@ -3,13 +3,6 @@ const config = JSON.parse(document.getElementById('dashboard-config').textContent); const root = document.body; - for (const [property, value] of Object.entries({ - '--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))', - '--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))', - '--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT, - '--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))', - '--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch', - })) root.style.setProperty(property, value); function node(tag, className, text) { const element = document.createElement(tag); @@ -35,7 +28,9 @@ } } - const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0); + const imageSize = item => item.crop || item; + const aspectRatio = pages => pages.reduce((sum, item) => + sum + imageSize(item).width / imageSize(item).height, 0); const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/'); const dayFormatter = new Intl.DateTimeFormat('en-CA', { @@ -63,9 +58,9 @@ message, rotate, items: [], current: [], images: new Map(), index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false, }); - this.panel = document.getElementById(id); - this.stage = this.panel.querySelector('.image-stage'); - this.progress = this.panel.querySelector('.poster-progress'); + const panel = document.getElementById(id); + this.stage = panel.querySelector('.image-stage'); + this.progress = panel.querySelector('.poster-progress'); new ResizeObserver(() => this.resize()).observe(this.stage); } @@ -75,7 +70,7 @@ const style = getComputedStyle(this.stage); const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); - const gap = this.rotate ? parseFloat(style.columnGap) : 0; + const gap = parseFloat(style.columnGap); const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0; if (!this.rotate) return { groups: [this.items], width, height, gap, frame }; const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100 @@ -134,6 +129,23 @@ image.dataset.source = item.url; image.src = localUrl; await image.decode(); + if (!this.rotate) { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d'); + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let left = canvas.width, top = canvas.height, right = 0, bottom = 0; + for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) { + if (!pixels[(y * canvas.width + x) * 4 + 3]) continue; + left = Math.min(left, x); top = Math.min(top, y); + right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1); + } + if (right) Object.defineProperty(item, 'crop', { + value: { left, top, width: right - left, height: bottom - top }, configurable: true, + }); + } return image; }).catch(() => { if (localUrl) URL.revokeObjectURL(localUrl); @@ -159,7 +171,7 @@ this.message = message; this.items = items; // Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden. - for (const item of items) this.loadImage(item); + const loading = items.map(item => this.loadImage(item)); if (!items.length) { this.generation++; clearTimeout(this.timer); @@ -180,7 +192,7 @@ this.pending = true; return; } - return this.show(0); + return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0)); } resize() { @@ -219,6 +231,8 @@ const documents = this.rotate ? groupDocuments(batch) : [batch]; const renderHeight = Math.min(layout.height - 2 * layout.frame, (layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch)); + const monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)), + (layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0)); const generation = ++this.generation; clearTimeout(this.timer); const sheet = node('div', 'image-sheet'); @@ -229,13 +243,23 @@ if (this.rotate) sheet.append(wrapper); return pages.map(async item => { const cell = node('div', 'image-cell'); - cell.style.height = renderHeight + 'px'; - cell.style.width = (renderHeight * item.width / item.height) + 'px'; + const size = imageSize(item); + cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px'; + cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px'; wrapper.append(cell); const copy = (await this.loadImage(item))?.cloneNode(); if (copy) { try { await copy.decode(); + if (item.crop) { + cell.classList.add('is-cropped'); + Object.assign(copy.style, { + width: item.width / size.width * 100 + '%', + height: item.height / size.height * 100 + '%', + left: -size.left / size.width * 100 + '%', + top: -size.top / size.height * 100 + '%', + }); + } cell.append(copy); return; } catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ } @@ -324,7 +348,8 @@ if (!imageData) return; // Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation. const months = imageData.month_images; - if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months)); + if (months.length) root.style.setProperty('--month-aspect', + months.reduce((sum, item) => sum + item.width / item.height, 0)); return Promise.all([ month.update(months), posters.update(imageData.posters.filter(item => item.expires >= currentDay)), @@ -371,13 +396,76 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric', }); + const clock = document.getElementById('clock'); + const date = document.getElementById('date'); + function fitClock() { + const width = clock.getBoundingClientRect().width; + const target = date.getBoundingClientRect().width; + if (width && Math.abs(width - target) > 1.5) { + clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px'; + } + } + new ResizeObserver(fitClock).observe(date); + + const departures = new FixedText('departures-panel', rows => { + const list = node('ul', 'text-page departures-list'); + for (const item of rows) { + const row = node('li', 'departure-row is-' + item.state); + row.title = item.detail; + row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction), + node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status)); + list.append(row); + } + return list; + }); + const departureData = { departures: [], updatedAt: 0 }; + let nextDepartureRequest = 0; + function renderDepartures(now) { + const age = now - departureData.updatedAt; + // Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die + // Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs. + const fresh = age < 300000; + const rows = (age < 7200000 ? departureData.departures : []) + .map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) })) + .filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now) + .sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => { + const live = fresh && Number.isFinite(d.delay) && Boolean(d.when); + const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled' + : d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime'; + const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'pünktlich', ontime: 'pünktlich', + delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state]; + const minutes = Math.ceil((d.timestamp - now) / 60000); + const planned = Date.parse(d.plannedWhen); + return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt', + remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt', + detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '') + + (d.direction || '') + ' · ' + status }; + }); + departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.'); + } + const refreshDepartures = createRefresh('departures_eich', data => { + if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige BVG-Antwort'); + Object.assign(departureData, data); + nextDepartureRequest = Number(data.retryAt) * 1000 || 0; + renderDepartures(Date.now()); + }, () => { + departureData.unavailable = true; + nextDepartureRequest = Date.now() + 30000; + renderDepartures(Date.now()); + }); function tick() { const now = new Date(); const nextDay = berlinDay(now); - document.getElementById('clock').textContent = timeFormat.format(now); - document.getElementById('clock').dateTime = now.toISOString(); - document.getElementById('date').textContent = dateFormat.format(now); - document.getElementById('date').dateTime = nextDay; + clock.textContent = timeFormat.format(now); + clock.dateTime = now.toISOString(); + date.textContent = dateFormat.format(now); + date.dateTime = nextDay; + fitClock(); + renderDepartures(now.getTime()); + if (!document.hidden && now.getTime() >= nextDepartureRequest) { + nextDepartureRequest = now.getTime() + 1000; + refreshDepartures(); + } if (nextDay !== currentDay) { currentDay = nextDay; // Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene @@ -392,7 +480,25 @@ imageData = data; return layoutImages(); }, imagesUnavailable); + const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 }); + const refreshTemperatures = [ + ['indoor', data => [data.indoor.value, data.indoor.unit]], + ['outdoor', data => [data.outdoor.temperature, '°C']], + ].map(([id, read]) => { + const output = document.getElementById(id); + return createRefresh(id, data => { + const [value, unit] = read(data); + if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur'); + const text = temperatureFormat.format(value) + ' ' + unit; + if (output.textContent !== text) output.textContent = text; + output.classList.remove('is-unavailable'); + }, () => { + if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar'; + }); + }); function refreshAll() { + // Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern. + refreshTemperatures.forEach(refresh => refresh()); return Promise.all([refreshContent(), refreshImages()]); } posters.onCycle = refreshAll; @@ -400,6 +506,7 @@ tick(); setInterval(tick, 1000); document.fonts.ready.then(() => { + fitClock(); announcements.rebuild(); bible.rebuild(); // Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft. diff --git a/web/intranet_repo/projects/dashboard/dashboard.php b/web/intranet_repo/projects/dashboard/dashboard.php index 002fa09..f2555c2 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.php +++ b/web/intranet_repo/projects/dashboard/dashboard.php @@ -20,29 +20,34 @@ $config = require __DIR__ . '/config.php';
-
-

Ansagen

-

Ansagen werden geladen …

-
- -
-
-
- Neuapostolische Kirche -
- Neuapostolische Kirche - Berlin-Reinickendorf -
-
+
+
+
+
Innen
Wird geladen …
+
Außen
Wird geladen …
+
+
+

Ansagen

+

Ansagen werden geladen …

+
+
+ +

Bibelwort für heute

Bibelwort wird geladen …

+
+
+

Abfahrten: Eichborndamm/Antonienstr.

+
+

Abfahrten werden geladen …

+
diff --git a/web/intranet_repo/projects/dashboard/data.php b/web/intranet_repo/projects/dashboard/data.php index a588d56..abcfcd9 100644 --- a/web/intranet_repo/projects/dashboard/data.php +++ b/web/intranet_repo/projects/dashboard/data.php @@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin'); require_once __DIR__ . '/util/dashboard_helpers.php'; $resource = (string) ($_GET['resource'] ?? 'content'); -$pdo = null; try { switch ($resource) { @@ -29,6 +28,10 @@ try { case 'outdoor': $payload = ['outdoor' => dashboard_fetch_outdoor_weather()]; break; + case 'departures_eich': + require_once __DIR__ . '/util/bvg.php'; + $payload = dashboard_fetch_departures(); + break; default: http_response_code(400); $payload = ['error' => 'Unbekannte Dashboard-Ressource.']; @@ -39,6 +42,4 @@ try { http_response_code(502); error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage()); echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE); -} finally { - $pdo = null; } diff --git a/web/intranet_repo/projects/dashboard/stylo.css b/web/intranet_repo/projects/dashboard/stylo.css index b94ea95..ae36a68 100644 --- a/web/intranet_repo/projects/dashboard/stylo.css +++ b/web/intranet_repo/projects/dashboard/stylo.css @@ -13,7 +13,6 @@ body.dashboard { --announcement-detail-font: calc(20 * var(--dashboard-unit)); --announcement-meta-font: calc(15 * var(--dashboard-unit)); --announcement-leading: 1.35; - --announcement-measure: 85ch; --bible-font: calc(23 * var(--dashboard-unit)); display: block; height: 100vh; @@ -27,47 +26,52 @@ body.dashboard { .dashboard [hidden] { display: none !important; } .dashboard-main { display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px); - grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height) + 2px) minmax(0, 1fr); - grid-template-areas: "community announcements month" "posters posters posters"; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect)); + grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr); + grid-template-areas: "announcements bible month" "posters posters posters"; gap: var(--space-sm); height: 100%; min-height: 0; padding: var(--space-sm); } -#month-panel { grid-area: month; --panel-accent: var(--nak-darkblue); } +#month-panel { grid-area: month; --panel-accent: var(--nak-purple); } #poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; } -#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); } -.community-column { grid-area: community; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--space-sm); min-height: 0; min-width: 0; } -.dashboard-overview { background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: calc(12 * var(--dashboard-unit)); padding: calc(8 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)); } -.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); } -.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); } -.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); } -.dashboard-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; } -.dashboard-wordmark strong { font-weight: 500; } -.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); } -.dashboard-clock time { display: block; } -#clock { font-size: calc(48 * var(--dashboard-unit)); line-height: 1; font-weight: 500; font-variant-numeric: tabular-nums; } +#announcements-panel { --panel-accent: var(--nak-teal); } +.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; } +.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); } +.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; } +.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); } +.dashboard-overview { display: flex; align-items: flex-end; justify-content: space-between; gap: calc(8 * var(--dashboard-unit)); padding: calc(16 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); } +.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); } +#clock, #date { width: max-content; } +#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; } #date { font-size: var(--announcement-meta-font); white-space: nowrap; } -.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); border: 1px solid var(--gray-300); border-radius: 0; } +.temperatures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; gap: calc(8 * var(--dashboard-unit)); margin: 0; } +.temperatures > div { min-width: 0; } +.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); } +.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; } +.temperatures .is-unavailable { font-size: calc(12 * var(--dashboard-unit)); font-weight: 300; color: var(--dashboard-meta); } .image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); } -.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); } +.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; background: var(--panel-surface); background: color-mix(in srgb, var(--panel-accent) 12%, white); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); } .panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; } .poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; } .poster-progress-track { stroke: var(--gray-300); } .poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; } .image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); } +#month-panel .image-stage, #poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); } .image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; } -#month-panel .image-sheet { gap: 0; } +#month-panel .image-sheet { align-items: flex-start; gap: calc(4 * var(--dashboard-unit)); } .image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; } .poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); } .image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; } +.image-cell.is-cropped { position: relative; overflow: hidden; } +.image-cell.is-cropped img { position: absolute; max-width: none; } .image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); } .text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; } #announcements-panel .text-stage { margin: 0; } #announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); } -.text-page { max-width: var(--announcement-measure); color: var(--dashboard-text); } +.text-page { color: var(--dashboard-text); } .text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); } .text-page p:last-child { margin-bottom: 0; } .text-page .announcement-title, .text-page .bible-reference { font-weight: 500; } @@ -83,7 +87,21 @@ body.dashboard { .announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; } .announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); } .bible-panel { --panel-accent: var(--nak-yellow); } +.bible-panel .text-stage { margin-inline: calc(24 * var(--dashboard-unit)); margin-top: calc(12 * var(--dashboard-unit)); } .bible-panel .text-page { max-width: none; } +#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); } +#departures-panel .text-stage { margin: 0; } +#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); } +.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; } +.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(80 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; } +.departure-row:nth-child(even) { background: var(--gray-50); } +.departure-row.is-ontime, .departure-row.is-scheduled { --departure-color: var(--nak-darkgreen); } +.departure-row.is-delayed { --departure-color: var(--nak-orange); } +.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); } +.departure-line { font-weight: 500; } +.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); } .text-stage.is-overflowing::after { content: ""; position: absolute; @@ -100,7 +118,7 @@ body.dashboard { .dashboard-main { height: auto; grid-template-columns: minmax(0, 1fr); - grid-template-areas: "community" "announcements" "month" "posters"; - grid-template-rows: 520px 320px 65vh 55vh; + grid-template-areas: "announcements" "bible" "month" "posters"; + grid-template-rows: 520px 520px 65vh 55vh; } } diff --git a/web/intranet_repo/projects/dashboard/util/bvg.php b/web/intranet_repo/projects/dashboard/util/bvg.php new file mode 100644 index 0000000..5d24259 --- /dev/null +++ b/web/intranet_repo/projects/dashboard/util/bvg.php @@ -0,0 +1,53 @@ += ($cache['retryAt'] ?? 0)) { + try { + $data = dashboard_http_json('https://v6.bvg.transport.rest/stops/' . DASHBOARD_BVG_STOP . '/departures?' + . 'duration=120&results=30&bus=true&suburban=false&subway=false&tram=false' + . '&ferry=false&express=false®ional=false&remarks=false&language=de&pretty=false'); + if (!isset($data['departures']) || !is_array($data['departures'])) { + throw new RuntimeException('Unerwartete BVG-Antwort.'); + } + $now = microtime(true); + $cache = ['departures' => array_values(array_filter($data['departures'], static fn(array $d): bool => + ($d['line']['product'] ?? '') === 'bus' + && in_array($d['stop']['id'] ?? '', [DASHBOARD_BVG_STOP, '900000' . substr(DASHBOARD_BVG_STOP, 3)], true))), + 'updatedAt' => (int) round($now * 1000), 'retryAt' => min($now + 12, (floor($now / 60) + 1) * 60), + 'unavailable' => false]; + } catch (Throwable $error) { + // Letzte Antwort behalten; auch bei 429/503 nicht sofort erneut anfragen. + error_log('Dashboard BVG: ' . $error->getMessage()); + $cache['unavailable'] = true; + $cache['retryAt'] = microtime(true) + 30; + } + rewind($file); + $json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) { + throw new RuntimeException('BVG-Cache konnte nicht gespeichert werden.'); + } + } + return $cache + ['departures' => [], 'updatedAt' => 0]; + } finally { + fclose($file); + } +} diff --git a/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php index 254b3ba..6f2504f 100644 --- a/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php +++ b/web/intranet_repo/projects/dashboard/util/dashboard_helpers.php @@ -8,8 +8,8 @@ require_once __DIR__ . '/../../../general_files/ini/runtime_env.php'; const DASHBOARD_TIMEZONE = 'Europe/Berlin'; const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur'; -const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291'; -const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422'; +const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524'; +const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494'; const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/'; function dashboard_now(): DateTimeImmutable @@ -91,15 +91,12 @@ function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $ $place = trim((string) ($announcement['ansage_ort'] ?? '')); return [ - 'start' => $start, - 'end' => $end, 'date_label' => $label, 'end_label' => $endLabel, 'time' => $time, 'place' => $place, 'title' => trim((string) ($announcement['ansage_titel'] ?? '')), 'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')), - 'meta' => implode(' · ', array_filter([$label, $endLabel, $time !== '' ? $time . ' Uhr' : '', $place])), ]; } @@ -146,8 +143,8 @@ function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONT $title = str_replace('_', ' ', substr($match[1], 11)); $posters[] = $image + [ 'document' => $match[1], - 'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2], - 'title' => $title, 'alt' => $title . ' · Seite ' . (int) $match[2], + 'expires' => $expiry, 'page' => (int) $match[2], + 'alt' => $title . ' · Seite ' . (int) $match[2], ]; } } @@ -162,10 +159,7 @@ function dashboard_month_images(?string $directory = null): array $filename = 'terminuebersicht-' . $page . '.png'; $image = dashboard_image($directory . '/' . $filename, 'month/' . $filename); if ($image) { - $images[] = $image + [ - 'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan', - 'alt' => 'Monatsplan · Seite ' . $page, - ]; + $images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page]; } } return $images; @@ -183,7 +177,7 @@ function dashboard_fetch_indoor_temperature(): array } return [ - 'value' => round((float) $data['state'], 1), + 'value' => (int) ceil((float) $data['state'] - 0.4), 'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'), ]; } diff --git a/worker/pdf_python/create_pdf.py b/worker/pdf_python/create_pdf.py index ab227ae..393662d 100644 --- a/worker/pdf_python/create_pdf.py +++ b/worker/pdf_python/create_pdf.py @@ -61,12 +61,11 @@ def has_style(value, declaration): def event_rows(cell): - blocks = cell.find_all("div", class_="calEventBox") - titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em")) - - for index, title in enumerate(titles): - time_text, place = "", "--" - block = blocks[index] if index < len(blocks) else None + titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em")) + + for title in titles: + time_text = "" + block = title.find_parent("div", class_="calEventBox") details = block and block.find( style=lambda value: has_style(value, "font-weight:normal") ) @@ -74,9 +73,10 @@ def event_rows(cell): parts = [part.strip() for part in details.stripped_strings if part.strip()] if parts and ":" in parts[0]: time_text = parts.pop(0) - if parts: - place = parts[0].lstrip(", ") or "--" - yield time_text, title.get_text(strip=True), place + # Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen. + location = block and block.find(attrs={"name": "show_place"}) + place = location.get_text(" ", strip=True).lstrip(", ") if location else "" + yield time_text, title.get_text(strip=True), "" if place == "--" else place def next_month(month): @@ -204,10 +204,14 @@ def add_month(pdf, cells, month, today=None): def add_month_section(pdf, cells, month, now, today=None, planning_note=False): - name = MONTHS[month.month - 1] - pdf.set_font("helvetica", "B", 16) - pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1) - pdf.set_font("helvetica", "", 10) + name = MONTHS[month.month - 1] + pdf.set_font("helvetica", "B", 16) + pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1) + logo = os.path.join(os.path.dirname(__file__), "nak_logo.png") + if not os.path.isfile(logo): + raise RuntimeError(f"Logo fehlt: {logo}") + pdf.image(logo, 188, pdf.get_y() - 7.5, 11) + pdf.set_font("helvetica", "", 10) subtitle = ( f"Die Termine für {name} befinden sich noch in Planung und können sich ändern." if planning_note else "der Gemeinde Berlin-Reinickendorf" @@ -230,14 +234,11 @@ def create_pdf(cells, output_path, now=None): pdf.set_auto_page_break(auto=True, margin=15) pdf.add_page() - logo = os.path.join(os.path.dirname(__file__), "nak_logo.png") - if not os.path.isfile(logo): - raise RuntimeError(f"Logo fehlt: {logo}") - pdf.image(logo, 188, 9.5, 11) - - for index, month in enumerate(months): - if index: - pdf.cell(0, 6, " ", 0, 1) + for index, month in enumerate(months): + if index and pdf.page_no() == 1: + pdf.add_page() + elif index: + pdf.cell(0, 6, " ", 0, 1) add_month_section( pdf, cells, month, now, today=today if index == 0 else None, -- 2.54.0