feat: Tab „Führungen" für ausgefallene und verschobene Sonderführungen — Version 1.14.0

Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen
Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus
SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für
jeden angemeldeten BEO.

Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert
der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide
Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt
sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar.

Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs,
Design in scripts/anleitung.template.html), automatisch vor jedem Build.
public/anleitung.html ist deshalb nicht mehr in git.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:03:43 +02:00
parent 1945f1f8e0
commit b3fbc391b1
14 changed files with 674 additions and 497 deletions
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bedienungsanleitung Führungsbuch Sternwarte Welzheim</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.65;
color: #1a1a2e;
background: #f4f6fb;
}
.page {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
background: linear-gradient(135deg, #1a2d5a 0%, #2e4e8a 100%);
color: #fff;
padding: 2.5rem 2rem 2rem;
border-radius: 12px;
margin-bottom: 2.5rem;
display: flex;
align-items: center;
gap: 1.2rem;
}
header .star { font-size: 2.8rem; flex-shrink: 0; line-height: 1; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.25; }
header p { margin-top: 0.3rem; font-size: 0.9rem; opacity: 0.75; }
nav.toc {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.4rem 1.8rem;
margin-bottom: 2.5rem;
}
nav.toc h2 {
font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: #6b7a9b; margin-bottom: 0.75rem;
}
nav.toc ol {
list-style: none; counter-reset: toc;
display: grid; grid-template-columns: 1fr 1fr; gap: 0.3rem 2rem;
}
nav.toc li { counter-increment: toc; display: flex; align-items: baseline; gap: 0.5rem; }
nav.toc li::before {
content: counter(toc) "."; font-size: 0.8rem; color: #85b7d7;
font-weight: 700; min-width: 1.4rem;
}
nav.toc a { color: #2e4e8a; text-decoration: none; font-size: 0.92rem; }
nav.toc a:hover { text-decoration: underline; }
section {
background: #fff;
border: 1px solid #d8e0f0;
border-radius: 10px;
padding: 1.8rem 2rem;
margin-bottom: 1.5rem;
}
h2.section-title {
font-size: 1.15rem; font-weight: 700; color: #1a2d5a;
margin-bottom: 1.2rem; padding-bottom: 0.6rem;
border-bottom: 2px solid #85b7d7;
display: flex; align-items: center; gap: 0.6rem;
}
h2.section-title .num {
background: #85b7d7; color: #1a2d5a; font-size: 0.78rem; font-weight: 800;
width: 1.6rem; height: 1.6rem; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
}
h3 {
font-size: 0.95rem; font-weight: 700; color: #2e4e8a;
margin: 1.3rem 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em;
}
h3:first-child { margin-top: 0; }
p { margin-bottom: 0.7rem; }
p:last-child { margin-bottom: 0; }
ul, ol { padding-left: 1.4rem; margin-bottom: 0.7rem; }
li { margin-bottom: 0.35rem; }
li:last-child { margin-bottom: 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin: 0.8rem 0; }
th {
background: #eef2fa; color: #2e4e8a; font-weight: 700;
text-align: left; padding: 0.5rem 0.9rem; border: 1px solid #d0d8ee;
}
td { padding: 0.45rem 0.9rem; border: 1px solid #e0e6f5; vertical-align: top; }
tr:nth-child(even) td { background: #f7f9fd; }
code {
background: #eef2fa; color: #1a2d5a;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85em; padding: 0.1em 0.35em; border-radius: 4px;
}
.callout {
background: #eef6fb; border-left: 4px solid #85b7d7;
border-radius: 0 8px 8px 0; padding: 0.8rem 1rem;
margin: 0.9rem 0; font-size: 0.9rem;
}
.callout.warn { background: #fff8ec; border-left-color: #f5a623; }
.callout.danger { background: #fff0f0; border-left-color: #e05252; }
strong { font-weight: 700; }
.back-btn {
display: inline-flex; align-items: center; gap: 0.4rem;
margin-bottom: 1.5rem; padding: 0.5rem 1rem;
background: #fff; border: 1px solid #d8e0f0; border-radius: 8px;
color: #2e4e8a; font-size: 0.9rem; font-weight: 600;
text-decoration: none; cursor: pointer;
}
.back-btn:hover { background: #eef2fa; }
@media (max-width: 600px) {
nav.toc ol { grid-template-columns: 1fr; }
header h1 { font-size: 1.25rem; }
section { padding: 1.2rem 1rem; }
}
@media print {
body { background: #fff; font-size: 12pt; }
.page { max-width: none; padding: 0; }
header { border-radius: 0; background: #1a2d5a !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
section { border: 1px solid #ccc; break-inside: avoid; }
nav.toc { break-after: page; }
a { color: inherit; text-decoration: none; }
.back-btn { display: none; }
}
</style>
</head>
<body>
<div class="page">
<a href="/" class="back-btn">← Zurück zum Führungsbuch</a>
<header>
<div class="star"></div>
<div>
<h1>{{H1}}</h1>
<p>{{SUBTITLE}}</p>
</div>
</header>
<nav class="toc">
<h2>Inhaltsverzeichnis</h2>
<ol>
{{TOC}}
</ol>
</nav>
{{SECTIONS}}
</div>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
/**
* Erzeugt public/anleitung.html aus ANLEITUNG.md.
*
* ANLEITUNG.md ist die einzige Quelle des Handbuchs; die HTML-Fassung wird nie von Hand
* bearbeitet (sie steht in .gitignore). Aufruf: `npm run anleitung` — läuft zusätzlich
* automatisch vor jedem `npm run build` (prebuild) und damit auch im Docker-Image.
*
* Abbildung Markdown → HTML:
* # Titel → Kopfzeile der Seite (vor „–" = H1, danach = Untertitel)
* ## Inhaltsverzeichnis → nav.toc, die Sprungziele werden auf #s1..#sN umgeschrieben
* ## N. Titel → <section id="sN"> mit nummeriertem Abschnittskopf
* --- → entfällt (reine Trennlinie in der Markdown-Ansicht)
* > Text → Hinweisbox .callout
* > [!WARNUNG] Text → .callout warn (auch [!WARNING])
* > [!ACHTUNG] Text → .callout danger (auch [!CAUTION])
* > [!HINWEIS] Text → .callout (auch [!NOTE])
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { marked } from 'marked';
const wurzel = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUELLE = join(wurzel, 'ANLEITUNG.md');
const VORLAGE = join(wurzel, 'scripts', 'anleitung.template.html');
const ZIEL = join(wurzel, 'public', 'anleitung.html');
const ALERTS = {
WARNUNG: 'callout warn', WARNING: 'callout warn',
ACHTUNG: 'callout danger', CAUTION: 'callout danger', DANGER: 'callout danger',
HINWEIS: 'callout', NOTE: 'callout',
};
/** Blockquotes werden zu Hinweisboxen, optional mit Variante über [!WARNUNG] o. ä. */
function calloutsUmschreiben(html) {
return html.replace(/<blockquote>\s*([\s\S]*?)\s*<\/blockquote>/g, (_, inhalt) => {
let klasse = 'callout';
const markierung = inhalt.match(/^<p>\s*\[!([A-ZÄÖÜ]+)\]\s*/);
if (markierung && ALERTS[markierung[1]]) {
klasse = ALERTS[markierung[1]];
inhalt = inhalt.replace(markierung[0], '<p>');
}
// Eine einzelne Textzeile braucht kein <p> — das spart eine Zeile Abstand in der Box.
const einzeln = inhalt.match(/^<p>([\s\S]*?)<\/p>$/);
return `<div class="${klasse}">${einzeln ? einzeln[1] : inhalt}</div>`;
});
}
/** Markdown-Sprungziele wie #7-drucken auf die Abschnitts-IDs #s7 umschreiben. */
function ankerUmschreiben(html) {
return html.replace(/href="#(\d+)-[^"]*"/g, (_, nummer) => `href="#s${nummer}"`);
}
function einrücken(html, stufe = ' ') {
return html.split('\n').map((z) => (z.trim() ? stufe + z : z)).join('\n');
}
const markdown = readFileSync(QUELLE, 'utf8');
// 1. Seitentitel aus der H1
const h1 = markdown.match(/^#\s+(.+)$/m);
if (!h1) throw new Error('ANLEITUNG.md: H1-Überschrift (# Titel) fehlt');
const [titelKopf, titelUnter = ''] = h1[1].split(/\s+[–—-]\s+/, 2);
// 2. In Blöcke je H2 zerlegen
const bloecke = markdown
.split(/^##\s+/m)
.slice(1)
.map((b) => {
const umbruch = b.indexOf('\n');
return {
titel: b.slice(0, umbruch).trim(),
rumpf: b.slice(umbruch + 1).replace(/^\s*---\s*$/gm, '').trim(),
};
});
// 3. Inhaltsverzeichnis: die Nummern der Listenpunkte werden zu #s<N>
const tocBlock = bloecke.find((b) => b.titel.toLowerCase() === 'inhaltsverzeichnis');
if (!tocBlock) throw new Error('ANLEITUNG.md: Abschnitt „## Inhaltsverzeichnis" fehlt');
const toc = [...tocBlock.rumpf.matchAll(/^\s*(\d+)\.\s*\[([^\]]+)\]/gm)]
.map(([, nr, text]) => ` <li><a href="#s${nr}">${text}</a></li>`)
.join('\n');
if (!toc) throw new Error('ANLEITUNG.md: Inhaltsverzeichnis enthält keine Einträge');
// 4. Nummerierte Abschnitte
const abschnitte = [];
for (const block of bloecke) {
const kopf = block.titel.match(/^(\d+)\.\s+(.*)$/);
if (!kopf) continue; // Inhaltsverzeichnis und alles ohne Nummer
const [, nummer, titel] = kopf;
const rumpf = ankerUmschreiben(calloutsUmschreiben(marked.parse(block.rumpf)));
abschnitte.push(
` <!-- ── ${nummer}. ${titel} ── -->\n` +
` <section id="s${nummer}">\n` +
` <h2 class="section-title"><span class="num">${nummer}</span> ${titel}</h2>\n` +
`${einrücken(rumpf.trim())}\n` +
` </section>`
);
}
if (!abschnitte.length) throw new Error('ANLEITUNG.md: keine nummerierten Abschnitte (## 1. …) gefunden');
const html = readFileSync(VORLAGE, 'utf8')
.replace('{{H1}}', titelKopf.trim())
.replace('{{SUBTITLE}}', titelUnter.trim())
.replace('{{TOC}}', toc)
.replace('{{SECTIONS}}', abschnitte.join('\n\n'));
writeFileSync(ZIEL, html, 'utf8');
console.log(`anleitung.html erzeugt — ${abschnitte.length} Abschnitte, ${html.length} Zeichen`);