Author SHA1 Message Date
samuelbias c01da71468 Merge pull request 'dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view' (#32) from feature/dashboard-rewrite-v2 into main
Reviewed-on: #32
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-06 05:46:18 +02:00
samuelbias 7d3f99c7b8 dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view 2026-09-06 05:42:27 +02:00
samuelbias b5f784c120 Merge pull request 'template formatting improved' (#31) from dev into main
Reviewed-on: #31
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-06 05:24:46 +02:00
samuelbias 214a3eb910 Merge pull request 'contentpanel - template formatting improved' (#30) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #30
2026-09-06 05:21:27 +02:00
samuelbias 055b6d10c4 contentpanel - template formatting improved 2026-09-06 05:17:37 +02:00
samuelbias 91d5f3d422 Merge pull request 'redesign - new dashboard, new contentpanel template functionality, monatsplan fix' (#29) from dev into main
Reviewed-on: #29
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-04 14:31:09 +02:00
samuelbias 8ba41eb71e Merge pull request 'contentpanel - test fix' (#28) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #28
2026-09-04 14:23:39 +02:00
samuelbias 2d88c64e48 contentpanel - test fix 2026-09-04 14:22:30 +02:00
samuelbias 5c7401f3bc Merge pull request 'Feature/dashboard rewrite v2' (#27) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #27
2026-09-04 13:27:47 +02:00
samuelbias 01b4be301f dashboard - whole redesign and rewrite 2026-09-04 13:15:08 +02:00
samuelbias 9abf97a922 Merge pull request 'contentpanel - Aushangworkflow um docx Vorlage erweitern' (#25) from feature/contentpanel/aushangsvorlage into dev
Reviewed-on: #25
2026-09-02 21:14:36 +02:00
samuelbias b5233bb6eb Merge pull request 'feature/developerpanel - linked dev instance and sso' (#21) from feature/developerpanel into main
Reviewed-on: #21
2026-08-29 22:58:45 +02:00
samuelbias 9c34e504ca Merge pull request 'feature/developerpanel - linked dev instance and sso' (#22) from feature/developerpanel into dev
Reviewed-on: #22
2026-08-29 22:56:58 +02:00
samuelbias ab58e13b88 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#19) from bugfix/docker-compose into main
Reviewed-on: #19
2026-08-29 22:12:31 +02:00
samuelbias 9c3aef4a96 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#20) from bugfix/docker-compose into dev
Reviewed-on: #20
2026-08-29 22:11:52 +02:00
samuelbias a238ba1a55 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#17) from bugfix/docker-compose into main
Reviewed-on: #17
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-08-29 21:57:11 +02:00
samuelbias e8305771f8 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#18) from bugfix/docker-compose into dev
Reviewed-on: #18
2026-08-29 21:56:34 +02:00
samuelbias 6988aee875 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#16) from bugfix/docker-compose into main
Reviewed-on: #16
2026-08-29 21:51:55 +02:00
samuelbias 2e33f2da38 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#15) from bugfix/docker-compose into dev
Reviewed-on: #15
2026-08-29 21:51:03 +02:00
samuelbias 494bfa8884 Merge pull request 'ACHTUNG: Refactoring - Rewrite des Intranet Quellcodes' (#14) from dev into main
Reviewed-on: #14
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-08-29 21:37:33 +02:00
samuelbias 37e7e4e367 Merge pull request 'monatsplan - ckalender read fixed, developerpanel - rss feed implemented' (#13) from bugfix/monatsplan into dev
Reviewed-on: #13
2026-08-29 21:28:07 +02:00
samuelbias 0314bced40 Merge pull request 'web - refactor core modules, harden authorization, simplify endpoints' (#11) from refactor/whole-web-application into dev
Reviewed-on: #11
2026-08-29 11:27:24 +02:00
markusvonhobe 3c56459cb8 Merge pull request 'project/developerpanel - all necessary websites linked' (#7) from feature/new-developerpanel into main
Reviewed-on: #7
Reviewed-by: Markus von Hobe <markus.vonhobe@gmail.com>
2026-08-28 23:23:43 +02:00
samuelbias 643d1a49d1 Merge pull request 'Feature/new developerpanel' (#6) from feature/new-developerpanel into dev
Reviewed-on: #6
2026-08-28 16:03:29 +02:00
15 changed files with 375 additions and 140 deletions
+1
View File
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
# Generierte Dashboard-Terminbilder # Generierte Dashboard-Terminbilder
web/intranet_repo/projects/dashboard/month/* web/intranet_repo/projects/dashboard/month/*
web/intranet_repo/projects/dashboard/cache/
# Lokale Backup-Dateien # Lokale Backup-Dateien
*.bak *.bak
+2
View File
@@ -51,6 +51,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \ /var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \ /var/www/html/intranet_repo/projects/dashboard/month \
&& chown -R www-data:www-data \ && chown -R www-data:www-data \
/data \ /data \
@@ -58,6 +59,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \ /var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \ /var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \ /var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \ /var/www/html/intranet_repo/projects/dashboard/month \
&& chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py && chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py
@@ -4,16 +4,10 @@ declare(strict_types=1);
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt. // Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
return [ return [
// Original: (97vh - 7px) × 47,5% Feld × 90,5% Leiste × 95% Bildhöhe.
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125, 'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875, 'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
'POSTER_MAX_VISIBLE_PAGES' => 12, 'POSTER_MAX_VISIBLE_PAGES' => 12,
'POSTER_MIN_DWELL_TIME_S' => 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, 'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen. // Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
'EMPTY_REFRESH_MS' => 30000, 'EMPTY_REFRESH_MS' => 30000,
+131 -23
View File
@@ -3,13 +3,6 @@
const config = JSON.parse(document.getElementById('dashboard-config').textContent); const config = JSON.parse(document.getElementById('dashboard-config').textContent);
const root = document.body; 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) { function node(tag, className, text) {
const element = document.createElement(tag); 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 layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
const dayFormatter = new Intl.DateTimeFormat('en-CA', { const dayFormatter = new Intl.DateTimeFormat('en-CA', {
@@ -63,9 +58,9 @@
message, rotate, items: [], current: [], images: new Map(), message, rotate, items: [], current: [], images: new Map(),
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false, index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
}); });
this.panel = document.getElementById(id); const panel = document.getElementById(id);
this.stage = this.panel.querySelector('.image-stage'); this.stage = panel.querySelector('.image-stage');
this.progress = this.panel.querySelector('.poster-progress'); this.progress = panel.querySelector('.poster-progress');
new ResizeObserver(() => this.resize()).observe(this.stage); new ResizeObserver(() => this.resize()).observe(this.stage);
} }
@@ -75,7 +70,7 @@
const style = getComputedStyle(this.stage); const style = getComputedStyle(this.stage);
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); 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; const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
if (!this.rotate) return { groups: [this.items], width, height, gap, frame }; if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100 const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
@@ -134,6 +129,23 @@
image.dataset.source = item.url; image.dataset.source = item.url;
image.src = localUrl; image.src = localUrl;
await image.decode(); 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; return image;
}).catch(() => { }).catch(() => {
if (localUrl) URL.revokeObjectURL(localUrl); if (localUrl) URL.revokeObjectURL(localUrl);
@@ -159,7 +171,7 @@
this.message = message; this.message = message;
this.items = items; this.items = items;
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden. // 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) { if (!items.length) {
this.generation++; this.generation++;
clearTimeout(this.timer); clearTimeout(this.timer);
@@ -180,7 +192,7 @@
this.pending = true; this.pending = true;
return; return;
} }
return this.show(0); return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
} }
resize() { resize() {
@@ -191,9 +203,10 @@
clearTimeout(this.timer); clearTimeout(this.timer);
if (!this.rotate) return; if (!this.rotate) return;
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS; const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
const changing = this.items.length && this.layout().groups.length > 1;
this.progressAnimation?.cancel(); this.progressAnimation?.cancel();
this.progress.toggleAttribute('hidden', !this.items.length); this.progress.toggleAttribute('hidden', !changing);
if (this.items.length) { if (changing) {
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate( this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }], [{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
{ duration, easing: 'linear', fill: 'forwards' }); { duration, easing: 'linear', fill: 'forwards' });
@@ -219,6 +232,8 @@
const documents = this.rotate ? groupDocuments(batch) : [batch]; const documents = this.rotate ? groupDocuments(batch) : [batch];
const renderHeight = Math.min(layout.height - 2 * layout.frame, const renderHeight = Math.min(layout.height - 2 * layout.frame,
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch)); (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; const generation = ++this.generation;
clearTimeout(this.timer); clearTimeout(this.timer);
const sheet = node('div', 'image-sheet'); const sheet = node('div', 'image-sheet');
@@ -229,13 +244,23 @@
if (this.rotate) sheet.append(wrapper); if (this.rotate) sheet.append(wrapper);
return pages.map(async item => { return pages.map(async item => {
const cell = node('div', 'image-cell'); const cell = node('div', 'image-cell');
cell.style.height = renderHeight + 'px'; const size = imageSize(item);
cell.style.width = (renderHeight * item.width / item.height) + 'px'; 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); wrapper.append(cell);
const copy = (await this.loadImage(item))?.cloneNode(); const copy = (await this.loadImage(item))?.cloneNode();
if (copy) { if (copy) {
try { try {
await copy.decode(); 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); cell.append(copy);
return; return;
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ } } catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
@@ -324,7 +349,8 @@
if (!imageData) return; if (!imageData) return;
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation. // Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
const months = imageData.month_images; 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([ return Promise.all([
month.update(months), month.update(months),
posters.update(imageData.posters.filter(item => item.expires >= currentDay)), posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
@@ -371,13 +397,76 @@
const dateFormat = new Intl.DateTimeFormat('de-DE', { const dateFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric', 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() { function tick() {
const now = new Date(); const now = new Date();
const nextDay = berlinDay(now); const nextDay = berlinDay(now);
document.getElementById('clock').textContent = timeFormat.format(now); clock.textContent = timeFormat.format(now);
document.getElementById('clock').dateTime = now.toISOString(); clock.dateTime = now.toISOString();
document.getElementById('date').textContent = dateFormat.format(now); date.textContent = dateFormat.format(now);
document.getElementById('date').dateTime = nextDay; date.dateTime = nextDay;
fitClock();
renderDepartures(now.getTime());
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
nextDepartureRequest = now.getTime() + 1000;
refreshDepartures();
}
if (nextDay !== currentDay) { if (nextDay !== currentDay) {
currentDay = nextDay; currentDay = nextDay;
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene // Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
@@ -392,7 +481,25 @@
imageData = data; imageData = data;
return layoutImages(); return layoutImages();
}, imagesUnavailable); }, 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() { function refreshAll() {
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
refreshTemperatures.forEach(refresh => refresh());
return Promise.all([refreshContent(), refreshImages()]); return Promise.all([refreshContent(), refreshImages()]);
} }
posters.onCycle = refreshAll; posters.onCycle = refreshAll;
@@ -400,6 +507,7 @@
tick(); tick();
setInterval(tick, 1000); setInterval(tick, 1000);
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
fitClock();
announcements.rebuild(); announcements.rebuild();
bible.rebuild(); bible.rebuild();
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft. // Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
@@ -20,29 +20,34 @@ $config = require __DIR__ . '/config.php';
</head> </head>
<body class="dashboard"> <body class="dashboard">
<main class="dashboard-main" aria-label="Gemeindeinformationen"> <main class="dashboard-main" aria-label="Gemeindeinformationen">
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title"> <div class="dashboard-column announcements-column">
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div> <section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
</section>
<div class="community-column">
<section class="dashboard-overview" aria-label="Gemeinde, Datum und Uhrzeit">
<div class="dashboard-brand">
<img src="../_central/img/nak-logo.svg" alt="Neuapostolische Kirche" width="64" height="64">
<div class="dashboard-wordmark">
<span>Neuapostolische Kirche</span>
<strong>Berlin-Reinickendorf</strong>
</div>
</div>
<div class="dashboard-clock"> <div class="dashboard-clock">
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time> <time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
<time id="date">Datum wird geladen</time> <time id="date">Datum wird geladen</time>
</div> </div>
<dl class="temperatures">
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
</dl>
</section> </section>
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
</section>
</div>
<div class="dashboard-column bible-column">
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title"> <section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div> <div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div> <div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
</section> </section>
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
<div class="panel-heading">
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
</div>
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
</section>
</div> </div>
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title"> <section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php'; require_once __DIR__ . '/util/dashboard_helpers.php';
$resource = (string) ($_GET['resource'] ?? 'content'); $resource = (string) ($_GET['resource'] ?? 'content');
$pdo = null;
try { try {
switch ($resource) { switch ($resource) {
@@ -29,6 +28,10 @@ try {
case 'outdoor': case 'outdoor':
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()]; $payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
break; break;
case 'departures_eich':
require_once __DIR__ . '/util/bvg.php';
$payload = dashboard_fetch_departures();
break;
default: default:
http_response_code(400); http_response_code(400);
$payload = ['error' => 'Unbekannte Dashboard-Ressource.']; $payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
@@ -39,6 +42,4 @@ try {
http_response_code(502); http_response_code(502);
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage()); error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE); echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
} finally {
$pdo = null;
} }
+43 -24
View File
@@ -13,7 +13,6 @@ body.dashboard {
--announcement-detail-font: calc(20 * var(--dashboard-unit)); --announcement-detail-font: calc(20 * var(--dashboard-unit));
--announcement-meta-font: calc(15 * var(--dashboard-unit)); --announcement-meta-font: calc(15 * var(--dashboard-unit));
--announcement-leading: 1.35; --announcement-leading: 1.35;
--announcement-measure: 85ch;
--bible-font: calc(23 * var(--dashboard-unit)); --bible-font: calc(23 * var(--dashboard-unit));
display: block; display: block;
height: 100vh; height: 100vh;
@@ -27,49 +26,55 @@ body.dashboard {
.dashboard [hidden] { display: none !important; } .dashboard [hidden] { display: none !important; }
.dashboard-main { .dashboard-main {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px); 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) + 2px) minmax(0, 1fr); grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
grid-template-areas: "community announcements month" "posters posters posters"; grid-template-areas: "announcements bible month" "posters posters posters";
gap: var(--space-sm); gap: var(--space-sm);
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: var(--space-sm); 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; } #poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); } #announcements-panel { --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-column { display: grid; 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)); } .announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); } .bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); } .dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); } .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-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; } .dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
.dashboard-wordmark strong { font-weight: 500; } #clock, #date { width: max-content; }
.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); } #clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
.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; } #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); } .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; } .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 { 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-track { stroke: var(--gray-300); }
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; } .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); } .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)); } #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; } .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; } .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); } .poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-300); }
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; } .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); } .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; } .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 { margin: 0; }
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); } #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 { 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 p:last-child { margin-bottom: 0; }
.text-page .announcement-title { font-size: var(--announcement-detail-font); }
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; } .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-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); } .text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
@@ -83,7 +88,21 @@ body.dashboard {
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; } .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); } .announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
.bible-panel { --panel-accent: var(--nak-yellow); } .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; } .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 { .text-stage.is-overflowing::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -100,7 +119,7 @@ body.dashboard {
.dashboard-main { .dashboard-main {
height: auto; height: auto;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-areas: "community" "announcements" "month" "posters"; grid-template-areas: "announcements" "bible" "month" "posters";
grid-template-rows: 520px 320px 65vh 55vh; grid-template-rows: 520px 520px 65vh 55vh;
} }
} }
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
const DASHBOARD_BVG_STOP = '900086103';
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('BVG-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/bvg-' . DASHBOARD_BVG_STOP . '.json', 'c+');
if (!$file) {
throw new RuntimeException('BVG-Cache nicht verfügbar.');
}
try {
// Gemeinsamer Cache: alle 12 Sekunden und zusätzlich zum Minutenwechsel.
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('BVG-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($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&regional=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);
}
}
@@ -8,8 +8,8 @@ require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin'; const DASHBOARD_TIMEZONE = 'Europe/Berlin';
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur'; const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291'; const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422'; const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/'; const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
function dashboard_now(): DateTimeImmutable function dashboard_now(): DateTimeImmutable
@@ -91,15 +91,12 @@ function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $
$place = trim((string) ($announcement['ansage_ort'] ?? '')); $place = trim((string) ($announcement['ansage_ort'] ?? ''));
return [ return [
'start' => $start,
'end' => $end,
'date_label' => $label, 'date_label' => $label,
'end_label' => $endLabel, 'end_label' => $endLabel,
'time' => $time, 'time' => $time,
'place' => $place, 'place' => $place,
'title' => trim((string) ($announcement['ansage_titel'] ?? '')), 'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')), '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)); $title = str_replace('_', ' ', substr($match[1], 11));
$posters[] = $image + [ $posters[] = $image + [
'document' => $match[1], 'document' => $match[1],
'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2], 'expires' => $expiry, 'page' => (int) $match[2],
'title' => $title, 'alt' => $title . ' · Seite ' . (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'; $filename = 'terminuebersicht-' . $page . '.png';
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename); $image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
if ($image) { if ($image) {
$images[] = $image + [ $images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan',
'alt' => 'Monatsplan · Seite ' . $page,
];
} }
} }
return $images; return $images;
@@ -183,7 +177,7 @@ function dashboard_fetch_indoor_temperature(): array
} }
return [ return [
'value' => round((float) $data['state'], 1), 'value' => (int) ceil((float) $data['state'] - 0.4),
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'), 'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
]; ];
} }
+40 -10
View File
@@ -53,10 +53,37 @@ def _set_text(paragraph, text):
node.appendChild(document.createTextNode(part)) node.appendChild(document.createTextNode(part))
anchor.parentNode.insertBefore(node, anchor) anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor) anchor.parentNode.removeChild(anchor)
# Preserve verse/signature lines without stretching them to the full width.
if '\n' in text: if '\n' in text:
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'): paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
alignment.setAttributeNS(W, 'w:val', 'left')
def _items(text):
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
if not any(bullets):
return [(text, None)]
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
for i, (line, bullet) in enumerate(zip(lines, bullets))]
def _format_bullet(paragraph, compact):
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
document = paragraph.ownerDocument
tabs = document.createElementNS(W, 'w:tabs')
tab = document.createElementNS(W, 'w:tab')
tab.setAttributeNS(W, 'w:val', 'left')
tab.setAttributeNS(W, 'w:pos', '480')
tabs.appendChild(tab)
properties.insertBefore(tabs, anchor)
if compact:
spacing = document.createElementNS(W, 'w:spacing')
spacing.setAttributeNS(W, 'w:after', '0')
properties.insertBefore(spacing, anchor)
indent = document.createElementNS(W, 'w:ind')
indent.setAttributeNS(W, 'w:left', '480')
indent.setAttributeNS(W, 'w:hanging', '360')
properties.insertBefore(indent, anchor)
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None: def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
@@ -72,13 +99,16 @@ def fill_template(template: Path, output: Path, title: str, date_label: str, par
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')} used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
index = 1 index = 1
for text in paragraphs: for text in paragraphs:
paragraph = body.cloneNode(deep=True) for value, compact in _items(text):
while f'{index:08X}' in used: paragraph = body.cloneNode(deep=True)
index += 1 while f'{index:08X}' in used:
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}') index += 1
used.add(f'{index:08X}') paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
_set_text(paragraph, text) used.add(f'{index:08X}')
body.parentNode.insertBefore(paragraph, body) _set_text(paragraph, f'•\t{value}' if compact is not None else value)
if compact is not None:
_format_bullet(paragraph, compact)
body.parentNode.insertBefore(paragraph, body)
body.parentNode.removeChild(body) body.parentNode.removeChild(body)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, 'w') as target: with zipfile.ZipFile(output, 'w') as target:
@@ -151,6 +151,30 @@ class PipelineTests(unittest.TestCase):
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber']) self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left') self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
def test_bullet_lines_become_hanging_list_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'bullets.docx'
fill_template(TEMPLATE, target, 'Einladung', _date_label('2026-09-04'), [
'Einleitung.',
'SEPTEMBER\n•05.09.2026: Erster Stichpunkt.\n• Zweiter Stichpunkt.\nOKTOBER\n• Dritter Stichpunkt.',
'Abschluss.',
])
with zipfile.ZipFile(target) as archive:
document = ET.fromstring(archive.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
q = lambda name: '{' + ns['w'] + '}' + name
bullets = [paragraph for paragraph in document.findall('.//w:p', ns)
if ''.join(paragraph.itertext()).startswith('•')]
self.assertEqual(len(bullets), 3)
self.assertTrue({'SEPTEMBER', 'OKTOBER'} <= {''.join(p.itertext()) for p in document.findall('.//w:p', ns)})
for paragraph in bullets:
self.assertIsNotNone(paragraph.find('w:r/w:tab', ns))
self.assertEqual(paragraph.find('w:pPr/w:tabs/w:tab', ns).get(q('pos')), '480')
indent = paragraph.find('w:pPr/w:ind', ns)
self.assertEqual((indent.get(q('left')), indent.get(q('hanging'))), ('480', '360'))
self.assertEqual(bullets[0].find('w:pPr/w:spacing', ns).get(q('after')), '0')
self.assertIsNone(bullets[1].find('w:pPr/w:spacing', ns))
def test_template_fill_creates_multiple_paragraphs(self): def test_template_fill_creates_multiple_paragraphs(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / "filled.docx" target = Path(temp) / "filled.docx"
@@ -35,7 +35,9 @@ class RenderingTests(unittest.TestCase):
paragraphs = [ paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip() ("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
for _ in range(3) for _ in range(3)
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber", ] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
"• Ein zweiter Stichpunkt bestätigt den gleichmäßigen Abstand der Liste.",
"Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."] "Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
fill_template(TEMPLATE, cls.document, fill_template(TEMPLATE, cls.document,
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf", "Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
@@ -172,6 +172,7 @@ class WebPreviewTests(unittest.TestCase):
def test_original_upload_and_delete_through_real_page(self): def test_original_upload_and_delete_through_real_page(self):
self.page.locator('#news_date').fill('2030-12-31') self.page.locator('#news_date').fill('2030-12-31')
self.page.locator('#news_name').fill('Uploadtest') self.page.locator('#news_name').fill('Uploadtest')
self.page.locator('[name="format_mode"][value="original"]').check()
self.page.locator('#fileToUpload').set_input_files(self.pdf) self.page.locator('#fileToUpload').set_input_files(self.pdf)
self.page.get_by_role('button', name='Datei hochladen').click() self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert', timeout=60000) self.page.wait_for_selector('.sb-alert', timeout=60000)
@@ -215,9 +216,9 @@ class WebPreviewTests(unittest.TestCase):
self.page.get_by_role('button', name='Datei hochladen').click() self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert') self.page.wait_for_selector('.sb-alert')
self.assertEqual(states[-1], ['block', True]) self.assertEqual(states[-1], ['block', True])
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text()) message = self.page.locator('.sb-alert').inner_text()
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text()) self.assertIn('Hintergrund', message)
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text()) self.assertIn('E-Mail', message)
self.assertFalse(list(self.news.glob('*' + title + '*'))) self.assertFalse(list(self.news.glob('*' + title + '*')))
self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled()) self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled())
self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0) self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0)
+14 -13
View File
@@ -61,12 +61,11 @@ def has_style(value, declaration):
def event_rows(cell): def event_rows(cell):
blocks = cell.find_all("div", class_="calEventBox")
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em")) titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
for index, title in enumerate(titles): for title in titles:
time_text, place = "", "--" time_text = ""
block = blocks[index] if index < len(blocks) else None block = title.find_parent("div", class_="calEventBox")
details = block and block.find( details = block and block.find(
style=lambda value: has_style(value, "font-weight:normal") 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()] parts = [part.strip() for part in details.stripped_strings if part.strip()]
if parts and ":" in parts[0]: if parts and ":" in parts[0]:
time_text = parts.pop(0) time_text = parts.pop(0)
if parts: # Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
place = parts[0].lstrip(", ") or "--" location = block and block.find(attrs={"name": "show_place"})
yield time_text, title.get_text(strip=True), 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): def next_month(month):
@@ -207,6 +207,10 @@ def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
name = MONTHS[month.month - 1] name = MONTHS[month.month - 1]
pdf.set_font("helvetica", "B", 16) pdf.set_font("helvetica", "B", 16)
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1) 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) pdf.set_font("helvetica", "", 10)
subtitle = ( subtitle = (
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern." f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
@@ -230,13 +234,10 @@ def create_pdf(cells, output_path, now=None):
pdf.set_auto_page_break(auto=True, margin=15) pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page() 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): for index, month in enumerate(months):
if index: if index and pdf.page_no() == 1:
pdf.add_page()
elif index:
pdf.cell(0, 6, " ", 0, 1) pdf.cell(0, 6, " ", 0, 1)
add_month_section( add_month_section(
pdf, cells, month, now, pdf, cells, month, now,