feat: Nutzungsanleitung + Legende auf /anleitung (#18)

- Neue Route /anleitung mit vollständiger Dokumentation
- 6 Abschnitte: Was ist der Tracker, Verfahrensstränge, Ampel-Legende,
  KI-Bewertung, konkrete Szenarien, Fristen-Tracking
- Lädt Strang-Definitionen von /api/ampel/definition
- Ampel-Beispiele inline mit Mock-Daten (inkl. Abzweigungen)
- Farbige Strang-Kästen, Inhaltsverzeichnis mit Sprungmarken
- Navigation in Desktop- und Mobile-Menü ergänzt
This commit is contained in:
Dotty Dotter 2026-04-02 23:09:44 +02:00
parent f5862406f3
commit f87ab389aa
4 changed files with 534 additions and 0 deletions

View File

@ -44,6 +44,7 @@
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a> <a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
<a href="/fristen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fristen</a> <a href="/fristen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fristen</a>
<a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a> <a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a>
<a href="/anleitung" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Anleitung</a>
</div> </div>
</div> </div>
<!-- Hamburger Button --> <!-- Hamburger Button -->
@ -80,6 +81,7 @@
<a href="/karte" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Karte</a> <a href="/karte" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Karte</a>
<a href="/fristen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fristen</a> <a href="/fristen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fristen</a>
<a href="/fraktionen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fraktionen</a> <a href="/fraktionen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fraktionen</a>
<a href="/anleitung" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Anleitung</a>
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,381 @@
<script lang="ts">
import { onMount } from 'svelte';
import Ampel from '$lib/components/Ampel.svelte';
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
let definition = $state<AmpelDefinition | null>(null);
let error = $state<string | null>(null);
// Mock-Ampeldaten pro Strang für die Beispiele
const MOCK_AMPELN: Record<string, {
schritte: { id: string; label: string; aktiv: boolean; erreicht: boolean; farbe: string }[];
abzweigung: { id: string; label: string; farbe: string } | null;
kontrollfrage: string | null;
beschreibung: string;
ablauf: string;
}> = {
antrag: {
schritte: [
{ id: 'eingereicht', label: 'Eingereicht', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beraten', label: 'Beraten', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beschlossen', label: 'Beschlossen', aktiv: true, erreicht: true, farbe: 'gelb' },
{ id: 'umgesetzt', label: 'Umgesetzt', aktiv: false, erreicht: false, farbe: 'grau' },
],
abzweigung: null,
kontrollfrage: 'Hat die Verwaltung umgesetzt, was beschlossen wurde?',
beschreibung: 'Politik will etwas verändern — die Verwaltung soll es umsetzen.',
ablauf: 'Antrag wird eingereicht → in Ausschüssen beraten → vom Rat beschlossen → von der Verwaltung umgesetzt (oder auch nicht).',
},
anfrage: {
schritte: [
{ id: 'gestellt', label: 'Gestellt', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beantwortet', label: 'Beantwortet', aktiv: true, erreicht: true, farbe: 'gruen' },
],
abzweigung: null,
kontrollfrage: 'Wurde die Anfrage befriedigend beantwortet?',
beschreibung: 'Politik fragt — die Verwaltung antwortet.',
ablauf: 'Anfrage wird gestellt → Verwaltung antwortet schriftlich → KI bewertet, ob die Antwort die Frage tatsächlich beantwortet.',
},
beschlussvorlage: {
schritte: [
{ id: 'eingebracht', label: 'Eingebracht', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beraten', label: 'Beraten', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beschlossen', label: 'Beschlossen', aktiv: true, erreicht: true, farbe: 'gelb' },
{ id: 'umgesetzt', label: 'Umgesetzt', aktiv: false, erreicht: false, farbe: 'grau' },
],
abzweigung: null,
kontrollfrage: 'Wurde so umgesetzt wie beschlossen?',
beschreibung: 'Die Verwaltung legt einen Vorschlag vor — der Rat stimmt zu.',
ablauf: 'Verwaltung bringt Vorlage ein → Beratung in Ausschüssen → Ratsbeschluss → Umsetzung durch die Verwaltung.',
},
mitteilung: {
schritte: [
{ id: 'vorgelegt', label: 'Vorgelegt', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'kenntnisnahme', label: 'Kenntnisnahme', aktiv: true, erreicht: true, farbe: 'gruen' },
],
abzweigung: null,
kontrollfrage: null,
beschreibung: 'Die Verwaltung informiert den Rat — keine Abstimmung, nur Kenntnisnahme.',
ablauf: 'Verwaltung legt Mitteilung vor → Rat nimmt zur Kenntnis.',
},
};
// Beispiel-Ampel mit Abzweigung (versandet)
const MOCK_ABZWEIGUNG = {
strang: 'antrag',
strang_label: 'Antrag (versandet)',
kontrollfrage: 'Hat die Verwaltung umgesetzt?',
schritte: [
{ id: 'eingereicht', label: 'Eingereicht', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beraten', label: 'Beraten', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beschlossen', label: 'Beschlossen', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'umgesetzt', label: 'Umgesetzt', aktiv: false, erreicht: false, farbe: 'grau' },
],
abzweigung: { id: 'versandet', label: 'Versandet', farbe: 'rot' },
};
const MOCK_ABGELEHNT = {
strang: 'antrag',
strang_label: 'Antrag (abgelehnt)',
kontrollfrage: null,
schritte: [
{ id: 'eingereicht', label: 'Eingereicht', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beraten', label: 'Beraten', aktiv: false, erreicht: true, farbe: 'gruen' },
{ id: 'beschlossen', label: 'Beschlossen', aktiv: false, erreicht: false, farbe: 'grau' },
{ id: 'umgesetzt', label: 'Umgesetzt', aktiv: false, erreicht: false, farbe: 'grau' },
],
abzweigung: { id: 'abgelehnt', label: 'Abgelehnt', farbe: 'rot' },
};
const STRANG_FARBEN: Record<string, { bg: string; border: string; text: string }> = {
antrag: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-800' },
anfrage: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-800' },
beschlussvorlage: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800' },
mitteilung: { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700' },
};
const STRANG_ORDER = ['antrag', 'anfrage', 'beschlussvorlage', 'mitteilung'];
const TOC = [
{ id: 'was-ist', label: 'Was ist der Antragstracker?' },
{ id: 'straenge', label: 'Die vier Verfahrensstränge' },
{ id: 'ampel', label: 'Die Ampel verstehen' },
{ id: 'ki-bewertung', label: 'Die KI-Bewertung' },
{ id: 'szenarien', label: 'Konkrete Szenarien' },
{ id: 'fristen', label: 'Fristen-Tracking' },
];
onMount(async () => {
try {
definition = await fetchAmpelDefinition();
} catch (e) {
error = 'Strang-Definitionen konnten nicht geladen werden.';
console.error(e);
}
});
function strangLabel(key: string): string {
if (definition?.straenge[key]) return definition.straenge[key].label;
const fallback: Record<string, string> = {
antrag: 'Anträge',
anfrage: 'Anfragen',
beschlussvorlage: 'Beschlussvorlagen',
mitteilung: 'Mitteilungen',
};
return fallback[key] || key;
}
function buildMockAmpel(key: string) {
const mock = MOCK_AMPELN[key];
if (!mock) return null;
return {
strang: key,
strang_label: strangLabel(key),
kontrollfrage: mock.kontrollfrage,
schritte: mock.schritte,
abzweigung: mock.abzweigung,
};
}
</script>
<svelte:head>
<title>Anleitung — Antragstracker Hagen</title>
</svelte:head>
<div class="max-w-3xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Anleitung</h1>
<p class="text-gray-500 mb-8">Wie der Antragstracker funktioniert — und wie du ihn nutzt.</p>
<!-- Inhaltsverzeichnis -->
<nav class="bg-white rounded-lg border border-gray-200 p-4 mb-10">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Inhalt</p>
<ol class="space-y-1">
{#each TOC as item}
<li>
<a href="#{item.id}" class="text-sm text-green-700 hover:text-green-900 hover:underline">
{item.label}
</a>
</li>
{/each}
</ol>
</nav>
<!-- 1. Was ist der Antragstracker? -->
<section id="was-ist" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Was ist der Antragstracker?</h2>
<div class="prose prose-gray max-w-none">
<p>
Der Antragstracker ist ein kommunalpolitisches Kontroll-Instrument für den Rat der Stadt Hagen.
Er verfolgt den Weg von Anträgen, Anfragen und Beschlüssen durch die Gremien — und macht sichtbar,
was beschlossen wurde und was davon tatsächlich umgesetzt wird.
</p>
<p>
Daten werden aus dem Ratsinformationssystem (Allris) importiert. Eine KI analysiert den aktuellen
Stand jedes Vorgangs und bewertet, ob beschlossene Maßnahmen umgesetzt wurden.
</p>
</div>
</section>
<!-- 2. Die vier Verfahrensstränge -->
<section id="straenge" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Die vier Verfahrensstränge</h2>
<p class="text-gray-600 mb-6">
Jeder Vorgang im Rat folgt einem von vier Verfahrenssträngen. Jeder Strang hat eigene Schritte,
eine eigene Ampel — und eine eigene Kontrollfrage.
</p>
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700 mb-4">{error}</div>
{/if}
<div class="space-y-6">
{#each STRANG_ORDER as key}
{@const farben = STRANG_FARBEN[key]}
{@const mock = MOCK_AMPELN[key]}
{@const ampelData = buildMockAmpel(key)}
<div class="{farben.bg} border {farben.border} rounded-lg p-5">
<h3 class="text-lg font-semibold {farben.text} mb-1">{strangLabel(key)}</h3>
<p class="text-sm text-gray-700 mb-3">{mock.beschreibung}</p>
{#if ampelData}
<div class="bg-white/60 rounded-md p-3 mb-3">
<Ampel ampel={ampelData} />
</div>
{/if}
{#if mock.kontrollfrage}
<p class="text-sm font-medium text-gray-800 mb-1">
Kontrollfrage: <span class="italic">{mock.kontrollfrage}</span>
</p>
{/if}
<p class="text-sm text-gray-600">
<span class="font-medium">Ablauf:</span> {mock.ablauf}
</p>
</div>
{/each}
</div>
</section>
<!-- 3. Die Ampel verstehen -->
<section id="ampel" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Die Ampel verstehen</h2>
<div class="prose prose-gray max-w-none mb-6">
<p>
Jeder Vorgang hat eine Ampel, die seinen Fortschritt zeigt. Die Schritte werden
von links nach rechts durchlaufen.
</p>
</div>
<div class="space-y-4 mb-6">
<div class="flex items-start gap-3">
<div class="w-5 h-5 rounded-full border-2 border-gray-300 bg-white shrink-0 mt-0.5"></div>
<div>
<p class="text-sm font-medium text-gray-900">Offen (weißer Kreis)</p>
<p class="text-sm text-gray-600">Dieser Schritt wurde noch nicht erreicht.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-5 h-5 rounded-full bg-gray-300 shrink-0 mt-0.5"></div>
<div>
<p class="text-sm font-medium text-gray-900">Durchlaufen (grau)</p>
<p class="text-sm text-gray-600">Dieser Schritt liegt in der Vergangenheit — er wurde bereits passiert.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-5 h-5 rounded-full bg-yellow-500 shrink-0 mt-0.5" style="box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.19)"></div>
<div>
<p class="text-sm font-medium text-gray-900">Aktuell — wartet (gelb)</p>
<p class="text-sm text-gray-600">Der Vorgang befindet sich gerade in diesem Schritt. Es passiert etwas — oder es wird darauf gewartet.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-5 h-5 rounded-full bg-green-500 shrink-0 mt-0.5" style="box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.19)"></div>
<div>
<p class="text-sm font-medium text-gray-900">Erledigt (grün)</p>
<p class="text-sm text-gray-600">Dieser Schritt ist abgeschlossen — der aktive Schritt leuchtet grün, wenn er positiv abgeschlossen ist.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-5 h-5 rounded-full bg-red-500 shrink-0 mt-0.5" style="box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.19)"></div>
<div>
<p class="text-sm font-medium text-gray-900">Gescheitert (rot)</p>
<p class="text-sm text-gray-600">Hier ist etwas schiefgegangen — abgelehnt, gescheitert oder nicht umgesetzt.</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-3">Abzweigungen</h3>
<p class="text-sm text-gray-600 mb-4">
Manchmal nimmt ein Vorgang nicht den normalen Weg. In diesen Fällen zeigt die Ampel eine Abzweigung:
</p>
<div class="space-y-4">
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-medium text-red-700 mb-2">Versandet</p>
<p class="text-sm text-gray-600 mb-3">
Ein Antrag wurde beschlossen, aber nie umgesetzt. Die Verwaltung hat ihn schlicht nicht weiterverfolgt.
</p>
<Ampel ampel={MOCK_ABZWEIGUNG} />
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-medium text-red-700 mb-2">Abgelehnt</p>
<p class="text-sm text-gray-600 mb-3">
Der Vorgang wurde in der Beratung oder Abstimmung abgelehnt und nicht weiterverfolgt.
</p>
<Ampel ampel={MOCK_ABGELEHNT} />
</div>
</div>
</section>
<!-- 4. Die KI-Bewertung -->
<section id="ki-bewertung" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Die KI-Bewertung</h2>
<div class="prose prose-gray max-w-none">
<p>
Jeder Vorgang wird von einer KI analysiert. Sie liest die zugehörigen Dokumente aus dem
Ratsinformationssystem und erstellt:
</p>
<ul>
<li><strong>Eine Zusammenfassung</strong> — worum geht es, was wurde beschlossen?</li>
<li><strong>Einen Umsetzungs-Score</strong> — wie weit ist die Umsetzung fortgeschritten (0100%)?</li>
</ul>
<h3>Was bedeutet der Prozentwert?</h3>
<ul>
<li><strong>0%</strong> — Keine erkennbare Umsetzung</li>
<li><strong>149%</strong> — Teilweise umgesetzt, wesentliche Teile fehlen</li>
<li><strong>5079%</strong> — Überwiegend umgesetzt, aber nicht vollständig</li>
<li><strong>80100%</strong> — Weitgehend bis vollständig umgesetzt</li>
</ul>
<h3>Neubewertung</h3>
<p>
Bewertungen können neu angefordert werden — etwa wenn neue Informationen vorliegen oder eine
Einschätzung fragwürdig erscheint. Dabei kann eine Anmerkung mitgegeben werden, die der KI
als zusätzlichen Kontext dient.
</p>
<h3>Versionierung</h3>
<p>
Jede Bewertung wird versioniert. Alte Bewertungen bleiben erhalten und sind nachvollziehbar.
So lässt sich verfolgen, wie sich die Einschätzung über die Zeit verändert hat.
</p>
</div>
</section>
<!-- 5. Konkrete Szenarien -->
<section id="szenarien" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Konkrete Szenarien</h2>
<div class="space-y-3">
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-semibold text-gray-900 mb-1">„Welche beschlossenen Anträge wurden nie umgesetzt?"</p>
<p class="text-sm text-gray-600">
<a href="/explorer" class="text-green-700 hover:underline">Explorer</a> öffnen, nach Status „versandet" filtern.
Zeigt alle Vorgänge, bei denen die Verwaltung beschlossene Maßnahmen nicht weiterverfolgt hat.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-semibold text-gray-900 mb-1">„Hat die Verwaltung meine Anfrage wirklich beantwortet?"</p>
<p class="text-sm text-gray-600">
→ Anfrage im Explorer suchen, KI-Bewertung lesen. Falls unbefriedigend: Neubewertung mit
eigener Anmerkung anfordern.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-semibold text-gray-900 mb-1">„Welche Fristen laufen demnächst ab?"</p>
<p class="text-sm text-gray-600">
<a href="/fristen" class="text-green-700 hover:underline">Fristen</a> öffnen.
Zeigt alle anstehenden und überfälligen Termine aus Beschlusstexten.
</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4">
<p class="text-sm font-semibold text-gray-900 mb-1">„Wie stimmt meine Fraktion im Vergleich zu anderen ab?"</p>
<p class="text-sm text-gray-600">
<a href="/abstimmungen" class="text-green-700 hover:underline">Abstimmungen</a> öffnen.
Vergleicht das Abstimmungsverhalten aller Fraktionen.
</p>
</div>
</div>
</section>
<!-- 6. Fristen-Tracking -->
<section id="fristen" class="mb-12 scroll-mt-24">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Fristen-Tracking</h2>
<div class="prose prose-gray max-w-none">
<p>
In vielen Beschlüssen stecken konkrete Termine: „bis Ende Q2 umsetzen", „Bericht bis März vorlegen".
Der Antragstracker erkennt solche Fristen automatisch per KI und macht sie sichtbar.
</p>
<ul>
<li><strong>Automatische Erkennung</strong> — Die KI extrahiert Termine aus Beschlusstexten</li>
<li><strong>Manuelle Ergänzung</strong> — Fristen können auch von Hand hinzugefügt werden</li>
<li><strong>Farbkodierung</strong> — Überfällige Fristen werden rot markiert, anstehende gelb</li>
</ul>
<p>
Alle Fristen sind unter <a href="/fristen">/fristen</a> einsehbar, gefiltert nach Status und Zeitraum.
</p>
</div>
</section>
</div>

View File

@ -0,0 +1,83 @@
#!/bin/bash
# Prüft Background-Jobs anhand ihrer Heartbeat-Dateien.
# Per Cron alle 5 Minuten aufrufen:
# */5 * * * * /path/to/antragstracker/scripts/check-background-jobs.sh >> /path/to/antragstracker/data/job-checker.log 2>&1
set -euo pipefail
cd "$(dirname "$0")/.."
DATA_DIR="data"
MAX_STALE_SECONDS=1800 # 30 Minuten ohne Update = stale
now=$(date +%s)
check_job() {
local name="$1"
local heartbeat_file="$2"
local restart_cmd="$3"
if [ ! -f "$heartbeat_file" ]; then
return 0 # Kein Heartbeat = Job nicht aktiv, OK
fi
local status=$(python3 -c "import json; d=json.load(open('$heartbeat_file')); print(d.get('status','unknown'))" 2>/dev/null || echo "error")
if [ "$status" = "completed" ]; then
echo "[$(date)] ✅ $name: fertig"
return 0
fi
# PID prüfen
local pid=$(python3 -c "import json; d=json.load(open('$heartbeat_file')); print(d.get('pid',''))" 2>/dev/null || echo "")
if [ -n "$pid" ] && [ "$pid" != "null" ] && [ "$pid" != "None" ]; then
if ! kill -0 "$pid" 2>/dev/null; then
echo "[$(date)] ❌ $name: Prozess $pid tot → Neustart"
eval "$restart_cmd"
return 0
fi
fi
# Timestamp prüfen
local last_batch=$(python3 -c "
import json, datetime
d = json.load(open('$heartbeat_file'))
ts = d.get('last_batch_at') or d.get('started_at') or ''
if ts:
dt = datetime.datetime.fromisoformat(ts.replace('Z', '+00:00'))
print(int(dt.timestamp()))
else:
print(0)
" 2>/dev/null || echo "0")
if [ "$last_batch" != "0" ]; then
local age=$((now - last_batch))
if [ "$age" -gt "$MAX_STALE_SECONDS" ]; then
echo "[$(date)] ⚠️ $name: Heartbeat ${age}s alt (max ${MAX_STALE_SECONDS}s) → Neustart"
# Kill alten Prozess
[ -n "$pid" ] && [ "$pid" != "null" ] && kill "$pid" 2>/dev/null || true
sleep 2
eval "$restart_cmd"
return 0
fi
fi
# Alles gut
local progress=$(python3 -c "
import json
d = json.load(open('$heartbeat_file'))
done = d.get('total_geocoded', d.get('total_done', '?'))
pending = d.get('total_pending', '?')
print(f'{done} done, {pending} pending')
" 2>/dev/null || echo "?")
echo "[$(date)] ✓ $name: läuft (PID $pid, $progress)"
}
# === Jobs registrieren ===
PROJ_DIR="$(pwd)"
check_job "Geocoding" \
"$DATA_DIR/geocode-heartbeat.json" \
"cd '$PROJ_DIR' && nohup bash scripts/geocode_background.sh >> data/geocode.log 2>&1 &"
# Weitere Jobs hier eintragen:
# check_job "OParl-Sync" "$DATA_DIR/sync-heartbeat.json" "..."

68
scripts/geocode_background.sh Executable file
View File

@ -0,0 +1,68 @@
#!/bin/bash
# Background Geocoding mit Heartbeat alle 20 Minuten
# Schreibt Status nach data/geocode-heartbeat.json
set -euo pipefail
cd "$(dirname "$0")/.."
HEARTBEAT_FILE="data/geocode-heartbeat.json"
BATCH_SIZE=1200 # ~20 Min bei 1/s
while true; do
START=$(date +%s)
START_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Run batch
OUTPUT=$(python3 scripts/geocode_pending.py --limit $BATCH_SIZE 2>&1) || true
END=$(date +%s)
DURATION=$((END - START))
# Parse output for stats
GEOCODED=$(echo "$OUTPUT" | grep -oP 'geocoded: \K\d+' 2>/dev/null || echo "0")
FAILED=$(echo "$OUTPUT" | grep -oP 'failed: \K\d+' 2>/dev/null || echo "0")
REMAINING=$(python3 -c "
import sqlite3
conn = sqlite3.connect('data/tracker.db')
r = conn.execute('SELECT COUNT(*) FROM orte WHERE lat IS NULL').fetchone()[0]
t = conn.execute('SELECT COUNT(*) FROM orte WHERE lat IS NOT NULL').fetchone()[0]
print(f'{r}|{t}')
conn.close()
" 2>/dev/null || echo "0|0")
PENDING=$(echo "$REMAINING" | cut -d'|' -f1)
DONE=$(echo "$REMAINING" | cut -d'|' -f2)
# Write heartbeat
cat > "$HEARTBEAT_FILE" << EOF
{
"status": "running",
"last_batch_at": "$START_ISO",
"duration_seconds": $DURATION,
"batch_geocoded": $GEOCODED,
"batch_failed": $FAILED,
"total_geocoded": $DONE,
"total_pending": $PENDING,
"pid": $$
}
EOF
echo "[$(date)] Batch done: +$GEOCODED geocoded, $FAILED failed, $PENDING remaining"
# Done?
if [ "$PENDING" = "0" ]; then
cat > "$HEARTBEAT_FILE" << EOF
{
"status": "completed",
"completed_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"total_geocoded": $DONE,
"total_pending": 0,
"pid": null
}
EOF
echo "✅ Geocoding fertig!"
exit 0
fi
# Kleine Pause zwischen Batches
sleep 5
done