Author SHA1 Message Date
samuelbias 718fbd74ad Merge pull request 'dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view' (#33) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #33
2026-09-06 05:43:40 +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 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 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 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 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 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 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 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
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
web/intranet_repo/projects/dashboard/month/*
web/intranet_repo/projects/dashboard/cache/
# Lokale Backup-Dateien
*.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/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \
&& chown -R www-data:www-data \
/data \
@@ -58,6 +59,7 @@ RUN mkdir -p \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/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
@@ -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,
+131 -23
View File
@@ -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() {
@@ -191,9 +203,10 @@
clearTimeout(this.timer);
if (!this.rotate) return;
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.progress.toggleAttribute('hidden', !this.items.length);
if (this.items.length) {
this.progress.toggleAttribute('hidden', !changing);
if (changing) {
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
{ duration, easing: 'linear', fill: 'forwards' });
@@ -219,6 +232,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 +244,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 +349,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 +397,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 +481,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 +507,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.
@@ -20,29 +20,34 @@ $config = require __DIR__ . '/config.php';
</head>
<body class="dashboard">
<main class="dashboard-main" aria-label="Gemeindeinformationen">
<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 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-column announcements-column">
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
<div class="dashboard-clock">
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
<time id="date">Datum wird geladen</time>
</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 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">
<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>
</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>
<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';
$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;
}
+43 -24
View File
@@ -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,49 +26,55 @@ 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); }
#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; }
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
#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); }
.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.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 { font-size: var(--announcement-detail-font); }
.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); }
@@ -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 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 +119,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;
}
}
@@ -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_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'),
];
}
+40 -10
View File
@@ -53,10 +53,37 @@ def _set_text(paragraph, text):
node.appendChild(document.createTextNode(part))
anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor)
# Preserve verse/signature lines without stretching them to the full width.
if '\n' in text:
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
alignment.setAttributeNS(W, 'w:val', 'left')
paragraph.getElementsByTagNameNS(W, 'jc')[0].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:
@@ -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')}
index = 1
for text in paragraphs:
paragraph = body.cloneNode(deep=True)
while f'{index:08X}' in used:
index += 1
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
used.add(f'{index:08X}')
_set_text(paragraph, text)
body.parentNode.insertBefore(paragraph, body)
for value, compact in _items(text):
paragraph = body.cloneNode(deep=True)
while f'{index:08X}' in used:
index += 1
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
used.add(f'{index:08X}')
_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)
output.parent.mkdir(parents=True, exist_ok=True)
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(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):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / "filled.docx"
@@ -35,7 +35,9 @@ class RenderingTests(unittest.TestCase):
paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
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."]
fill_template(TEMPLATE, cls.document,
"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):
self.page.locator('#news_date').fill('2030-12-31')
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.get_by_role('button', name='Datei hochladen').click()
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.wait_for_selector('.sb-alert')
self.assertEqual(states[-1], ['block', True])
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
message = self.page.locator('.sb-alert').inner_text()
self.assertIn('Hintergrund', message)
self.assertIn('E-Mail', message)
self.assertFalse(list(self.news.glob('*' + title + '*')))
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)
+14 -13
View File
@@ -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
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):
@@ -207,6 +207,10 @@ 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)
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."
@@ -230,13 +234,10 @@ 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:
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,