diff --git a/README.md b/README.md
index 9cc32f2..72596fc 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,12 @@ Dieses Verzeichnis ist zugleich der Web-Root der NAS
Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist
sofort live.
+Für zwei Teile reicht diese Landkarte nicht, weil ihr Verhalten aus dem
+Zusammenspiel von Editor, Datenbank und Runner entsteht. Sie haben eigene,
+ausführliche Dokumente unter [`doku/`](doku/README.md):
+**[Automatiken](doku/automatiken.md)** und
+**[Zeitleiste](doku/zeitleiste.md)**.
+
---
## Inhalt
diff --git a/doku/README.md b/doku/README.md
new file mode 100644
index 0000000..64c7690
--- /dev/null
+++ b/doku/README.md
@@ -0,0 +1,79 @@
+# Vertiefende Dokumentation
+
+Die [README im Wurzelverzeichnis](../README.md) beschreibt das Ganze: welche
+Seite es gibt, welche Datei was tut, wo eine Zahl herkommt. Sie ist die
+Landkarte.
+
+Hier liegen die **Tiefenbohrungen** — für die Teile, bei denen die Landkarte
+nicht reicht, weil das Verhalten aus dem Zusammenspiel mehrerer Prozesse
+entsteht und die Regeln nicht aus dem Quelltext einer einzelnen Datei
+abzulesen sind.
+
+| Dokument | Worum es geht |
+|---|---|
+| [automatiken.md](automatiken.md) | Die Automatiken (AutoActions): Datenmodell, Editor, Runner, wie eine Bedingung wirklich ausgewertet wird, Verkettung, Sperren, Fehlerbilder |
+| [zeitleiste.md](zeitleiste.md) | Die Zeitleiste der Automatiken: wie der Server den Tag ausrechnet und wie der Browser ihn zeichnet |
+
+---
+
+## Die drei Prozesse, die zusammenspielen
+
+Nichts hier ist eine einzelne Anwendung. Drei Dinge laufen gleichzeitig, und
+sie reden nur über MQTT und über die Datenbanken miteinander — nie direkt.
+
+```mermaid
+flowchart LR
+ subgraph Browser["Browser"]
+ UI["Weboberfläche
PHP + JS, /volume1/web/smart"]
+ end
+
+ subgraph NAS["NAS, Hintergrundprozesse (SolarManager)"]
+ RUN["autoaction_runner.py
führt Automatiken aus"]
+ SM["solarManager.py
sammelt Anlagenwerte"]
+ SK["gatherSkodaData.py
Fahrzeug"]
+ end
+
+ subgraph Speicher["Speicher"]
+ MQTT[["MQTT-Broker
alles Aktuelle"]]
+ HM[("homeMesh
Geräte, Automatiken, Grundriss")]
+ SL[("solarLog
Messreihen, Sonnenzeiten")]
+ end
+
+ subgraph Geraete["Geräte"]
+ TAH["Tahoma-Box
Jalousien"]
+ SHE["Shelly, WLED, ESP32"]
+ end
+
+ UI -- "liest/schreibt Regeln" --> HM
+ UI -- "abonniert" --> MQTT
+ UI -- "schaltet von Hand" --> TAH & SHE
+ RUN -- "Regelwerk" --> HM
+ RUN -- "Messwerte zurück" --> HM
+ RUN -- "Sonnenzeiten" --> SL
+ RUN -- "hört + schaltet" --> MQTT
+ RUN -- "liest + schaltet" --> TAH & SHE
+ SM --> MQTT & SL
+ SK --> SL
+```
+
+**Wichtig für das Verständnis aller folgenden Kapitel:**
+
+* Die **Weboberfläche schaltet nichts von selbst.** Sie beschreibt, was
+ gelten soll (`homeMesh`), und zeigt, was ist. Ausgeführt wird im Runner.
+* **`actor_states.current_value` pflegt der Runner.** Ohne ihn stünde dort
+ der Wert vom Tag des Gerätesuchlaufs. Deshalb zeigt auch der Editor
+ aktuelle Zahlen, obwohl er nur eine Tabelle liest.
+* Der Runner ist ein **Dauerprozess, kein Cronjob** — er braucht den
+ Vorzustand (`cond_met`), um steigende Flanken zu erkennen.
+
+---
+
+## Lesereihenfolge
+
+1. [README](../README.md), Abschnitt „Auf einen Blick“ — Seiten und Dateien.
+2. [automatiken.md](automatiken.md) — das Regelwerk und seine Ausführung.
+3. [zeitleiste.md](zeitleiste.md) — die Darstellung desselben Regelwerks
+ über einen Tag.
+
+Wer nur etwas ändern will, findet die Einstiegspunkte am Ende beider
+Dokumente unter „Wo fange ich an, wenn ich …“.
diff --git a/doku/automatiken.md b/doku/automatiken.md
new file mode 100644
index 0000000..d199eeb
--- /dev/null
+++ b/doku/automatiken.md
@@ -0,0 +1,409 @@
+# Die Automatiken (AutoActions)
+
+Eine Automatik ist ein Satz: **„Wenn … , dann …, aber nur wenn der Rahmen
+passt.“** Sie wird im Browser gebaut, liegt in der Datenbank `homeMesh` und
+wird von einem Dauerprozess auf der NAS ausgeführt.
+
+```
+ Rahmen Auslöser Aktionen
+ ────── ──────── ────────
+ Mo–Fr, Uhrzeit um 07:00 Magdalena Fenster: Auf
+ nicht in Ferien, UND Außentemperatur > −10 °C Magdalena Tür: Auf
+ 06:00–09:00,
+ Sperrzeit 15 min
+```
+
+Beteiligt sind drei Stellen:
+
+| Wo | Datei | Aufgabe |
+|---|---|---|
+| Browser | `js/solar/autoActionFuncs.js` | Editor: Modell bauen, zeichnen, abschicken |
+| Web-Server | `restricted/automations.php`, `ajax/AutoAction.php` | Laden, Prüfen, Speichern, Beschreiben |
+| NAS | `SolarManager/autoActions/autoaction_runner.py` | Auswerten und Ausführen |
+
+
+
+---
+
+## 1. Datenmodell
+
+Alles steht in `homeMesh` — dort, wo auch die Geräte liegen, damit
+Fremdschlüssel greifen können. Schema: [`homeMesh_automations.sql`](../homeMesh_automations.sql).
+
+```mermaid
+erDiagram
+ actors ||--o{ actor_states : "hat Messwerte"
+ actors ||--o{ actor_commands : "hat Kommandos"
+ actor_commands ||--o{ command_parameters : "hat Parameter"
+
+ automations ||--o{ automation_conditions : "Auslöser"
+ automations ||--o{ automation_actions : "Aktionen"
+ automations ||--o{ automation_log : "Protokoll"
+ automation_actions ||--o{ automation_action_params : "Werte"
+
+ actor_states ||--o{ automation_conditions : "state_id"
+ actor_commands ||--o{ automation_actions : "command_id"
+ command_parameters ||--o{ automation_action_params : "parameter_id"
+
+ automations {
+ int id
+ varchar name
+ varchar floor "Reiter in der Übersicht"
+ tinyint enabled "pausiert oder nicht"
+ time window_from "Rahmen: Zeitfenster"
+ time window_to
+ tinyint weekdays "Bitmaske Mo..So"
+ tinyint on_vacation "0 nie, 1 egal, 2 zusätzlich"
+ tinyint on_holiday "0 nie, 1 egal, 2 zusätzlich"
+ tinyint next_day "Rahmen gilt für morgen"
+ tinyint force_once "am Fensterende notfalls doch"
+ tinyint once_per_day "höchstens einmal am Tag"
+ int lockout_secs "Sperrzeit nach einem Lauf"
+ tinyint cond_met "Laufzustand: war die Bedingung zuletzt wahr?"
+ datetime last_run "Laufzustand: wann zuletzt ausgelöst"
+ timestamp changed "Signal an den Runner"
+ }
+ automation_conditions {
+ int automation_id
+ int group_no "gleich = UND, verschieden = ODER"
+ int state_id "zeigt auf actor_states"
+ enum operator "ASCII, nie das hübsche Zeichen"
+ varchar value "Schwelle als Text"
+ }
+```
+
+Drei Felder sind **nachgerüstet** und stehen deshalb nicht in der
+Schema-Datei, sondern in eigenen Skripten im SolarManager-Repo
+(`autoActions/`): `next_day` (`vorabend.sql`), `once_per_day` und die
+Dreiwertigkeit von `on_vacation`/`on_holiday` (`rahmen_erweitern.sql`),
+sowie der Datentyp `elapsed` samt gerechnetem Gerät „Automatiken“
+(`automatik_ausloeser.sql`). Wer die Datenbank neu aufsetzt, spielt sie nach
+`homeMesh_automations.sql` ein.
+
+Drei Spalten in `automations` sind **kein Regelwerk, sondern Laufzustand**:
+`cond_met`, `last_run` und `changed`. Sie werden vom Runner geschrieben.
+Wer eine Automatik per SQL kopiert, sollte sie leeren.
+
+### Warum `state_id` und nicht „Gerät + Feld“
+
+Eine Bedingung zeigt mit einer einzigen Zahl auf `actor_states`. Damit hängen
+Gerät, Messwertname, Datentyp, Einheit und Quelle (Topic, HTTP-Feld, Tahoma)
+an dieser Zeile. Der Editor muss nichts davon wissen: Er listet Geräte und
+deren Messwerte. Genau deshalb funktioniert auch die Verkettung
+(→ [Abschnitt 6](#6-verkettung-eine-automatik-löst-die-nächste-aus)) ohne eine
+einzige Sonderregel im Editor.
+
+---
+
+## 2. Der Editor
+
+Der Editor arbeitet **auf einem Modell und zeichnet daraus die Oberfläche** —
+nicht umgekehrt:
+
+```js
+autoModel.groups = [ [ {state_id, operator, value}, … ], … ] // innen UND, außen ODER
+autoModel.actions = [ {command_id, params: {parameterID: wert}}, … ]
+```
+
+Der Gerätekatalog (`deviceCatalog()`) kommt **im selben Dokument** mit, es
+gibt also kein Nachladen je Zeile.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant B as Browser
autoActionFuncs.js
+ participant A as ajax/AutoAction.php
+ participant M as restricted/automations.php
+ participant DB as homeMesh
+
+ B->>A: GET ?action=editor&id=42
+ A->>M: loadAutomation(42) + deviceCatalog()
+ M->>DB: SELECT automations / conditions / actions
+ A-->>B: HTML + Katalog + Datensatz (ein Dokument)
+ Note over B: autoModel füllen, rendern
+
+ loop alle 8 s, solange der Editor offen ist
+ B->>A: GET ?action=werte&ids=…
+ A->>DB: SELECT current_value FROM actor_states
+ A-->>B: aktuelle Messwerte → Punkte neben den Zeilen
+ end
+
+ B->>A: POST ?action=save (JSON)
+ A->>M: saveAutomation()
+ M->>M: pruefeKreis()
+ M->>DB: UPDATE automations; DELETE+INSERT conditions/actions
+ A-->>B: {ok, id}
+ Note over DB: changed = NOW() → der Runner lädt neu
+```
+
+Drei Eigenheiten, die man kennen sollte:
+
+* **Speichern heißt löschen und neu einfügen.** Bedingungen und Aktionen
+ bekommen dabei neue IDs. Deshalb reicht `changed` allein dem Runner nicht,
+ um Änderungen zu erkennen (→ [Abschnitt 4](#4-der-runner)).
+* **Live-Werte neben jeder Bedingung** kommen aus `actor_states`, nicht aus
+ MQTT: Über den Broker kommt nur ein Teil der Geräte (Jalousien hängen an
+ der Tahoma-Box, gerechnete Werte an gar nichts). Eine Tabelle, ein Zugriff,
+ alle Gerätearten.
+* **Kreise werden beim Speichern abgelehnt** (`pruefeKreis()`), nicht im
+ Betrieb geheilt. Im Protokoll sähe ein Kreis nur wie „läuft halt oft“ aus.
+
+### Der Rahmen in Worten
+
+`rahmenText()` im Editor und `describeConditions()` auf dem Server erzeugen
+denselben Satz in Alltagssprache („werktags, 06:00–09:00, höchstens einmal
+am Tag“). Wer die Bedeutung eines Feldes ändert, muss beide anfassen —
+sonst steht in der Übersicht etwas anderes als im Editor.
+
+---
+
+## 3. Die drei Teile einer Automatik
+
+### 3.1 Rahmen: darf sie überhaupt?
+
+| Feld | Bedeutung | Fallstrick |
+|---|---|---|
+| `enabled` | pausiert oder nicht | Pausierte laden der Runner gar nicht erst — ihre Nachfolger stehen mit still |
+| `weekdays` | Bitmaske, Bit 0 = Montag | |
+| `on_vacation`, `on_holiday` | **dreiwertig**: 0 nie, 1 egal, 2 zusätzlich | „zusätzlich“ zählt wie ein passender Wochentag; ein Verbot schlägt eine Erweiterung |
+| `next_day` | Der Rahmen gilt für **morgen** (Vorabend-Form) | Uhrzeit und Fenster bleiben bei heute |
+| `window_from`/`window_to` | Zeitfenster; `von > bis` heißt über Mitternacht | |
+| `lockout_secs` | Sperrzeit nach einem Lauf (0 / 60 / 900) | |
+| `once_per_day` | höchstens ein Lauf je Kalendertag | |
+| `force_once` | am Ende des Fensters notfalls doch ausführen | greift nur, wenn im Fenster gar nichts lief |
+
+Warum dreiwertig? „Wochenenden **und** Feiertage“ ließ sich vorher nicht
+schreiben: Die Wochentagsmaske kennt nur Sa und So, und ein Feiertag am
+Dienstag ist eben ein Dienstag. Mit „Feiertage: zusätzlich“ kommt er über
+den Kalender (`calendar_days`, gefüllt von `fetch_calendar.py`) herein.
+
+Warum `next_day`? „Kinderrollos zu, wenn **morgen** Schule ist“ ließ sich mit
+dem heutigen Tag nur annähern (So–Do, heute keine Ferien) — und das ging am
+letzten Ferientag, am Abend vor einem Feiertag und am Abend eines Feiertags
+daneben.
+
+### 3.2 Auslöser: `any(all(gruppe))`
+
+Bedingungen mit derselben `group_no` sind mit **UND** verknüpft, verschiedene
+Gruppen mit **ODER**. Im Editor ist eine Gruppe ein gerahmter Block und
+zwischen den Blöcken steht ein ODER — die Klammerung ist also gezeichnet und
+nicht bloß vereinbart.
+
+> Eine Automatik **ohne** Bedingung löst nie aus. Sonst würde sie nach einem
+> Gerätesuchlauf, der ihren Messwert entfernt hat, plötzlich dauernd feuern.
+
+### 3.3 Aktionen
+
+Eine Aktion ist ein Kommando eines Geräts plus je Parameter ein Wert
+(`automation_action_params`, eine Zeile je Parameter statt fester Spalten).
+Geschickt wird über denselben Transport-Zoo wie beim Lesen
+(→ [Abschnitt 4](#4-der-runner)); Jalousien bekommen dabei die
+Kugelschreiber-Mechanik (erst Neigung 0, dann das Ziel), siehe
+`TahomaTransport` und `sendeTahoma()` in `restricted/commands.php`.
+
+---
+
+## 4. Der Runner
+
+`autoaction_runner.py` läuft als Dauerprozess.
+
+```mermaid
+flowchart TB
+ START([Start]) --> LOAD["Regelwerk laden
Automatiken, Messwerte, Kommandos"]
+ LOAD --> SUB["MQTT abonnieren
Sammlerfäden starten"]
+ SUB --> TAKT
+
+ subgraph TAKT["Takt, alle tick_seconds (10 s)"]
+ direction TB
+ T1["Werte einsammeln
MQTT-Puffer + Queue der Sammler"]
+ T2["durchlauf(): jede Automatik prüfen"]
+ T3["Ergebnisse des Versands verbuchen"]
+ T4["alle reload_seconds: Signatur vergleichen"]
+ T1 --> T2 --> T3 --> T4
+ end
+
+ T4 -- "Signatur geändert" --> LOAD
+ T2 -- "Aktionen" --> VERSAND[["Versand
je Gerät der Reihe nach,
über Geräte hinweg parallel"]]
+
+ SAMMLER[["Sammler
HTTP/WLED 1 min,
Tahoma 5 min"]] -. "Queue" .-> T1
+```
+
+**Warum ein Dauerprozess und nicht Cron?**
+
+1. Schwellwerte („Temperatur über 22 °C“) sollen greifen, wenn die Nachricht
+ hereinkommt — nicht im nächsten Minutenraster.
+2. Nur so gibt es einen Vorzustand für die steigende Flanke.
+3. `actor_states.current_value` würde sonst niemand fortschreiben.
+
+**Warum drei Fäden?** Früher wurde mitten in der Hauptschleife gepollt:
+neunzehn Tahoma-Geräte nacheinander, jedes mit bis zu zehn Sekunden
+Zeitlimit. Eine Runde dauerte 45 statt 30 Sekunden, dadurch kam **jede
+dritte Minute nie vor** — und ein Auslöser „um 18:26“ wurde schlicht nie
+wahr. Heute hat jeder Transport seinen eigenen Faden, die Hauptschleife
+macht nur noch Billiges, und die Uhr wird gar nicht mehr abgetastet, sondern
+gegen `datetime.now()` verglichen.
+
+### Transporte
+
+| URL des Aktors | Transport | Lesen | Schreiben |
+|---|---|---|---|
+| `mqtt://…` | MQTT | abonniert, gepuffert | `publish` |
+| `http://…` | HTTP | Poll, 1 min | Abfrageargumente |
+| `wled://…` | WLED | Poll, 1 min | JSON an `/json/state` |
+| `io://`, `rts://`, `internal://`, `ogp://` | Tahoma | Poll, 5 min | `exec/apply` |
+| `Logic` | gerechnet | Uhrzeit, Datum, Sonnenauf-/-untergang | — |
+| `Automatik` | gerechnet | letzte Auslösung je Automatik | — |
+
+Ausnahme beim Lesen: Steht in `actor_states.url` ein **Topic**, liest der
+MQTT-Transport — auch wenn das Gerät über HTTP geschaltet wird. So sind die
+Shellys der zweiten Generation angebunden.
+
+### Woran der Runner merkt, dass er neu laden muss
+
+Nicht an `changed` allein — eine gelöschte Automatik ändert den größten
+Zeitstempel nicht, und wer nur die Uhrzeit einer Bedingung verstellt, ändert
+in `automations` gar keine Spalte. Stattdessen eine **Signatur**: Zeilenzahl
+plus Summe der `CRC32` je Zeile, über Bedingungen, Aktionen, Aktionsparameter
+*und* die Gerätetabellen (dort auch `url` und `value_path`, weil ein
+Discovery-Lauf bestehende Zeilen umschreibt).
+
+---
+
+## 5. Die Auswertung im Detail
+
+Das Herzstück, `durchlauf()`, für jede Automatik in topologischer Reihenfolge
+(Auslöser vor Nachfolger):
+
+```mermaid
+flowchart TB
+ A["Tag bestimmen
heute, mit next_day morgen"] --> B{"tag_passt?
Wochentag, Ferien, Feiertag"}
+ B -- nein --> Z1
+ B -- ja --> C{"im Zeitfenster?"}
+ C -- nein --> Z1["Fenster zu:
force_once prüfen,
Flanke zurücksetzen"]
+ C -- ja --> D{"Bedingung erfüllt?
any(all(gruppe))"}
+ D -- nein --> E["cond_met = 0"]
+ D -- ja --> F{"cond_met schon 1?"}
+ F -- ja --> G["nichts tun
keine neue Flanke"]
+ F -- nein --> H{"once_per_day
und lief heute?"}
+ H -- ja --> G
+ H -- nein --> I{"innerhalb
lockout_secs?"}
+ I -- ja --> J["Flanke verwerfen"]
+ I -- nein --> K["ausloesen()"]
+ K --> L["Aktionen in den Versand,
last_run + Protokoll,
eigenen Auslöserwert setzen"]
+```
+
+### Nur die steigende Flanke
+
+`cond_met` hält fest, ob die Bedingung beim letzten Durchlauf schon erfüllt
+war. Ohne das würde „Temperatur über 22 °C“ in jedem Takt erneut feuern.
+
+### Die drei Zeitformen
+
+| Schreibweise | Operator | Bedeutung | Gedacht für |
+|---|---|---|---|
+| `um 16:30` | `=` | ab dieser Minute, plus **Nachholfenster** (5 min) | der Normalfall: einmal täglich zu einem Termin |
+| `ab 16:30` | `>=` | von da bis Mitternacht wahr | alles, was auch nach einem verpassten Takt noch laufen soll (Bewässerung) |
+| `vor 16:30` | `<` | bis dahin wahr | als Zusatzbedingung |
+
+Ausgelöst wird in allen drei Fällen **höchstens einmal** — wegen der Flanke.
+Das Nachholfenster ersetzt die früher verlangte Punktgenauigkeit: Ein
+Neustart oder ein hängendes Gerät kostet die Automatik nicht mehr den ganzen
+Tag. Gerechnet wird modulo 24 h, damit ein Ziel um 23:58 auch um 00:01 noch
+zieht.
+
+```
+ Takt: ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ (alle 10 s)
+ │ │
+ um 16:30 └── wahr ──────┘ (16:30:00 bis 16:34:59, Nachholfenster)
+ ↑ Flanke: genau hier wird ausgelöst
+
+ ab 16:30 └── wahr ───────────────────────────────────► 23:59
+ ↑ Flanke: ebenfalls nur einmal
+```
+
+### Sonnenstand und Ketten
+
+Beim Sonnenauf-/-untergang trägt der Operator zusätzlich **das Vorzeichen des
+Versatzes**: `+ 00:30` = eine halbe Stunde danach, `>=- 00:30` = ab einer
+halben Stunde davor, `<+ 00:30` = bis eine halbe Stunde danach. Über den
+Tagesrand wird gerechnet und nicht abgeschnitten — „sechs Stunden vor
+Sonnenaufgang“ landet dann eben am Vorabend.
+
+Beim Datentyp `elapsed` (Verkettung) ist es fast dasselbe, mit einem
+wichtigen Unterschied: Verglichen wird der **echte Abstand**, nicht die
+Uhrzeit im Tag. Sonst machte ein Lauf von vorgestern um 05:50 die Bedingung
+heute um 06:00 wahr, an einem Tag, an dem der Auslöser gar nicht lief.
+`ab + 00:10` gilt zusätzlich nur am selben Kalendertag.
+
+### Sperrzeit, „einmal am Tag“, „auf jeden Fall“
+
+| Mittel | Wogegen | Verhalten |
+|---|---|---|
+| `lockout_secs` | Messwerte, die um die Schwelle pendeln (22,1 / 21,9 / 22,1) | Die Flanke wird **verworfen**, nicht aufgehoben — ein Rollladen, der eine Viertelstunde später doch losfährt, wäre unangenehmer als einer, der gar nicht fährt |
+| `once_per_day` | Dinge, die man hinterher von Hand anders stellt | Nach dem ersten Lauf ist bis Mitternacht Ruhe. Die Sperrzeit taugt dafür nicht: sie zählt Sekunden und verfehlte am nächsten Morgen den Termin |
+| `force_once` | verpasste Fenster | Beim Zugehen des Fensters, aber nur, wenn darin nichts lief (zusätzlich gegen `last_run` geprüft, damit ein Neustart nicht doppelt auslöst) |
+
+---
+
+## 6. Verkettung: eine Automatik löst die nächste aus
+
+Das gerechnete Gerät **„Automatiken“** führt jede Automatik als Messwert;
+ihr Wert ist der Zeitpunkt der letzten Auslösung (Datentyp `elapsed`, URL
+`auto:`). Damit ist eine Automatik für den Editor ein Messwert wie jeder
+andere — „Wecker Magdalena + 00:10“.
+
+```mermaid
+flowchart LR
+ W["Wecker Magdalena
um 07:00"] -->|"+ 00:10"| R["Magdalena Rollos
und es ist hell"]
+ R -->|"+ 00:05"| S["Schlafzimmer
Tür auf"]
+```
+
+Warum nicht einfach eine Verzögerung an der Aktion? Weil der Nachfolger eine
+**vollwertige Automatik mit eigenem Rahmen** bleiben soll: „zehn Minuten
+später, aber nur wenn es dann schon hell ist“ ließe sich als bloße
+Verzögerung nicht formulieren — die Zusatzbedingung gilt erst zum späteren
+Zeitpunkt.
+
+Was daran hängt:
+
+* **Reihenfolge:** `reihenfolge_bestimmen()` sortiert topologisch, damit ein
+ Versatz von null noch im selben Takt greift. Nach dem Auslösen trägt der
+ Runner den eigenen Auslöserwert sofort nach.
+* **Pausieren wirkt weiter:** Der Runner lädt nur `enabled = 1`. Wer den
+ Wecker pausiert, lässt morgens auch die Rollos unten — gewollt.
+* **Anlegen/Löschen der Messwerte** macht `ausloeser_nachfuehren()` vor jedem
+ Regelwerk-Laden. Angelegt wird auch für pausierte Automatiken, sonst
+ löschte `ON DELETE CASCADE` still die Bedingung des Nachfolgers.
+* **Löschen mit Anhang:** `deleteAutomation($id, "abhaengen"|"mitloeschen")`.
+ Der Editor fragt nach, wenn Nachfolger existieren.
+* **Kreise** lehnt der Server beim Speichern ab; der Runner meldet sie nur
+ und lässt die Beteiligten weiterlaufen.
+
+---
+
+## 7. Protokoll und Fehlerbilder
+
+`automation_log` bekommt je Lauf eine Zeile (`fired` / `forced`) und je
+fehlgeschlagenem Kommando eine weitere (`error`). Einträge älter als 30 Tage
+räumt der Runner selbst weg.
+
+| Beobachtung | Wahrscheinliche Ursache |
+|---|---|
+| Automatik läuft gar nicht, ohne Protokolleintrag | Rahmen: falscher Wochentag, Ferien/Feiertag auf „nie“, Fenster zu eng — oder die Bedingung war nie *neu* erfüllt (`cond_met` stand schon auf 1) |
+| Läuft einmal und dann nicht mehr am selben Tag | `once_per_day` |
+| Läuft kurz nacheinander nicht erneut | `lockout_secs` |
+| Kette bleibt stehen | Auslöser pausiert oder an dem Tag nicht dran |
+| Änderung im Editor wirkt nicht | Regelwerk-Signatur: erst beim nächsten `reload_seconds`-Fenster (30 s) |
+| `error` im Protokoll | Gerät nicht erreichbar, Kommando oder Transport gibt es nicht mehr |
+
+---
+
+## 8. Wo fange ich an, wenn ich …
+
+| Vorhaben | Ort |
+|---|---|
+| … einen neuen Operator oder Datentyp anbieten | `operatorsForType()` in `restricted/automations.php` **und** `bedingung_erfuellt()` im Runner |
+| … ein neues Rahmenfeld einführen | Spalte in `homeMesh_automations.sql`, `saveAutomation()`/`loadAutomation()`, Editor (`renderRahmen`, `leseFormular`), `durchlauf()` im Runner, Satz in `rahmenText()` |
+| … eine neue Geräteart anschließen | eine Klasse in `SolarManager/autoActions/transports.py` (Lesen + Senden), Erkennung an der Aktor-URL |
+| … verstehen, warum etwas lief | `automation_log`, dann die Zeitleiste des Tages (→ [zeitleiste.md](zeitleiste.md)) |
+| … das Nachholfenster ändern | `catchup_minutes` in `config.ini` des Runners |
diff --git a/doku/bilder/editor.png b/doku/bilder/editor.png
new file mode 100644
index 0000000..ec294d6
Binary files /dev/null and b/doku/bilder/editor.png differ
diff --git a/doku/bilder/zeitleiste.png b/doku/bilder/zeitleiste.png
new file mode 100644
index 0000000..bcf6764
Binary files /dev/null and b/doku/bilder/zeitleiste.png differ
diff --git a/doku/zeitleiste.md b/doku/zeitleiste.md
new file mode 100644
index 0000000..bb73f77
--- /dev/null
+++ b/doku/zeitleiste.md
@@ -0,0 +1,236 @@
+# Die Zeitleiste der Automatiken
+
+Die Übersicht als Tabelle beantwortete nicht, was man eigentlich wissen will:
+**Läuft der Tag stimmig, und warum ist etwas noch nicht gefahren?** Die
+Zeitleiste legt deshalb jede Automatik auf die Stunde, zu der sie greift.
+
+
+
+Zwei Dateien teilen sich die Arbeit, und die Trennung ist scharf:
+
+| Datei | Aufgabe |
+|---|---|
+| `restricted/zeitleiste.php` | **rechnen**: wo liegt eine Automatik auf dem Tag, in welcher Bahn, ist sie heute dran, ist sie gelaufen |
+| `js/solar/zeitleiste.js` | **zeichnen**: Bahnen, Stapeln, Etiketten, Kettenbögen, Karte, Filter |
+
+Geliefert wird über `ajax/AutoAction.php?action=zeitleiste&datum=YYYY-MM-DD`
+als JSON.
+
+---
+
+## 1. Was der Server ausrechnet
+
+```mermaid
+flowchart TB
+ A["Alle Automatiken laden
loadAutomation() je Id"] --> B["Bedingungen je ODER-Gruppe einsortieren"]
+ B --> C{"Was trägt die Gruppe?"}
+ C -->|"Uhrzeit =, Sonne ±"| P["punkt"]
+ C -->|"Uhrzeit >= / <"| F["start / ende"]
+ C -->|"Automatik + Versatz"| K["kette + versatz"]
+ C -->|"alles andere"| S["sensor = true
wartet auf einen Messwert"]
+ P & F & K & S --> D["Art bestimmen:
punkt › kette › fenster › jederzeit"]
+ D --> E["Ketten auflösen
bis zu 6 Runden"]
+ E --> G["Bahn, Satz, Tagesprüfung, Läufe"]
+ G --> H[["JSON"]]
+```
+
+### 1.1 Die Regeln, wie eine Lage entsteht
+
+| Bedingung | Ergebnis auf dem Tag |
+|---|---|
+| feste Uhrzeit `um 07:00` | **Punkt** |
+| Sonnenauf-/-untergang ± Versatz | **Punkt**, an diesem Tag ausgerechnet |
+| andere Automatik `+ 00:10` | **Kette**: Punkt hinter dem Auslöser |
+| `ab …` / `vor …` oder ein eingeengtes Zeitfenster | **Balken** über das Fenster |
+| nur Messwerte, ganzer Tag | **„jederzeit“** (eigenes Band unter der Leiste) |
+
+Mehrere Gruppen (ODER) werden zusammengefasst: Gibt es Punkte, gewinnt der
+früheste; sonst eine Kette; sonst das umschließende Fenster aus allen
+Gruppengrenzen. Trägt eine Gruppe einen zeitlich bestimmten Beginn (etwa
+„Sonnenuntergang + 30 **oder** dunkel“), merkt sich der Eintrag ihn als
+`punkt` — er erscheint im Hinweis als „frühestens 19:01“.
+
+### 1.2 Ketten auflösen
+
+Die Lage eines Nachfolgers hängt vom Auslöser ab, und die Kette kann mehrere
+Stufen haben (Wecker → Rollos → Schlafzimmer). Deshalb läuft die Auflösung
+reihum, höchstens sechs Runden:
+
+| Zustand des Auslösers | Nachfolger |
+|---|---|
+| ist an diesem Tag schon **gelaufen** | Punkt bei der echten Laufzeit + Versatz |
+| hat einen **Punkt** | Punkt dort + Versatz |
+| ist ein **Fenster** | Fenster ab dessen Beginn + Versatz |
+| hat selbst keine Lage / Kreis | „jederzeit“ |
+
+Immer begrenzt auf das eigene Zeitfenster. Und: Der Nachfolger **erbt**, ob
+der Tag passt — läuft der Wecker heute nicht, steht auch das Rollo
+gestrichelt da, mit dem Grund „Auslöser ‚Wecker Magdalena‘ ist nicht dran“.
+
+### 1.3 Ist sie heute überhaupt dran?
+
+`zlTagPasst()` rechnet **genau wie der Runner** (`tag_passt()` und
+`gemeinter_tag()`): erst öffnen (Wochentag, „zusätzlich“ an Ferien/Feiertagen),
+dann sperren (Ferien/Feiertage auf „nie“), und bei `next_day` für morgen.
+Sonst hieße „heute nicht“ hier etwas anderes als dort.
+
+> Ausgeblendet wird trotzdem nichts. Wer nicht dran ist, steht gestrichelt
+> da — mit Grund im Hinweis. Eine Zeitleiste, die Einträge verschweigt,
+> beantwortet die Frage „warum ist das nicht gefahren?“ gerade nicht.
+
+### 1.4 Sonnenzeiten für beliebige Tage
+
+`solarLog.daylight` kennt heute, morgen und die Vergangenheit. Für einen
+späteren Tag nimmt `zlSonne()` denselben Kalendertag eines Vorjahres — die
+Sonne verschiebt sich von Jahr zu Jahr um weniger als eine Minute, und so
+braucht es keinen Standort, der in einem anderen Haus wieder falsch wäre.
+Im Hinweis steht dann „(Vorjahr)“.
+
+### 1.5 Die Bahn
+
+Die Zeile, in der eine Automatik steht, ergibt sich aus den **Geräten, die
+sie schaltet** — dieselbe Einordnung wie im Raum-Modal (`bedienform()` in
+`roomControls.php`), Mehrheit gewinnt, bei Gleichstand die Bahn weiter oben.
+Bewässerung ist dort „sonstiges“ und wird am Gerätetyp erkannt.
+
+Bahnen: Licht · Beschattung · Heizung · Bewässerung · Schalter · Sonstiges.
+
+### 1.6 Das JSON
+
+```jsonc
+{
+ "datum": "2026-09-21", "heute": true, "jetzt": 964, // Minuten seit Mitternacht
+ "sonne": { "auf": 431, "unter": 1176, "genau": true },
+ "bahnen": { "licht": {"titel": "Licht", "symbol": "bi-lightbulb"}, … },
+ "etagen": [ {"code": "OG", "label": "Obergeschoss"}, … ],
+ "automatiken": [{
+ "id": 18, "name": "Hitzeschutz Süd", "floor": "EG", "enabled": true,
+ "dran": true, "grund": "",
+ "bahn": "beschattung",
+ "satz": "Wozi Schiebetür, Wozi Fensterfront +1: Position+Neigung",
+ "wenn": "Sonne Süd: Helligkeit > 30000 Lux und …",
+ "laeufe": ["12:50"], // aus automation_log
+ "art": "fenster", // punkt | fenster | kette | jederzeit
+ "punkt": 570, "von": 570, "bis": 1080,
+ "kette": null, "versatz": 0,
+ "sonne": false, // Lage kommt vom Sonnenstand
+ "wartet": true // hängt an einem Messwert
+ }]
+}
+```
+
+---
+
+## 2. Was der Browser daraus macht
+
+```mermaid
+flowchart TB
+ L["zeitleisteLaden()
fetch ?action=zeitleiste"] --> Z["zeitleisteZeichnen()"]
+ Z --> F["Etagenfilter anwenden"]
+ F --> T["Dreiteilung:
gelegt · jederzeit · pausiert"]
+ T --> ST["je Bahn: zlStapeln()
Zeilen wie im Kalender"]
+ ST --> HTML["HTML bauen:
Achse, Bahnen, Bänder, Legende"]
+ HTML --> KET["zlKettenZeichnen()
SVG-Bögen, nach dem Layout"]
+ KET --> SCR["Scrollstand setzen"]
+ SCR --> KAR["offene Karte wieder öffnen"]
+```
+
+Jede Automatik erscheint **genau einmal**: in ihrer Bahn, unter „Jederzeit“
+oder unter „Pausiert“. Der Zähler oben („22 von 22 · 3 pausiert“) ist die
+Probe aufs Exempel — fehlte eine, fiele es dort auf.
+
+### 2.1 Stapeln: warum in Pixeln und nicht in Minuten
+
+Zwei Punkte zehn Minuten auseinander liegen zeitlich getrennt — ihre
+**Beschriftungen** aber übereinander. `zlStapeln()` rechnet deshalb in
+Pixeln: Es misst den Etikett-Text (einmal ein Canvas, dann nur messen) und
+legt jeden Eintrag in die erste Zeile, in der er nichts überdeckt.
+
+```
+ Zeile 0 ●07:00 Wecker Magdalena ▭ 09:30–18:00 Hitzeschutz Süd ✓12:50
+ Zeile 1 ●07:08 Wecker Rollos ●15:59 Hitzeschutz Süd zurück
+ └── Nachfolger direkt unter seinem Auslöser
+```
+
+Drei Sonderfälle stecken darin:
+
+* **Punkt am rechten Rand** trägt sein Etikett links von sich — die Marke
+ bleibt, wo die Zeit ist.
+* **Fenster-Etikett** rückt nie vor seinen Balken; sonst sähe „15:00–18:31“
+ aus, als finge es um halb elf an. Reicht der Platz nicht, wird der Name
+ gekürzt (voll steht er im Hinweis).
+* **Ketten bleiben beisammen:** Eingesetzt wird familienweise — erst der
+ Auslöser, gleich danach seine Nachfolger in die erste freie Zeile
+ *darunter*. Je Zeile werden belegte Strecken geführt, nicht nur ihr Ende,
+ weil ein Nachfolger links von etwas liegen kann, das schon steht.
+
+### 2.2 Zustand, Farbe, Form
+
+`zlZustand()` kennt fünf Zustände — sie bestimmen Farbe und Zeichen:
+
+| Zustand | Wann | Darstellung |
+|---|---|---|
+| `nichtdran` | Tag passt nicht | gestrichelt, Kalendersymbol, Grund im Hinweis |
+| `gelaufen` | es gibt Läufe an dem Tag | Haken und echte Laufzeit |
+| `vorbei` | Zeit liegt hinter „jetzt“, nichts gelaufen | matt |
+| `aktiv` | „jetzt“ liegt im Fenster / hinter dem Punkt | hervorgehoben |
+| `kommt` | liegt noch vor uns | normal |
+
+Beim Punkt zeigt das Etikett nach einem Lauf die **echte** Zeit, beim Fenster
+immer das Fenster und den Lauf hinten mit Haken — sonst stünde „12:50“ am
+Balkenanfang bei 09:30.
+
+### 2.3 Kettenbögen
+
+Erst **nach** dem Einsetzen gezeichnet, weil die Lage beider Enden aus dem
+Layout kommt. Die Linie verlässt den Auslöser lotrecht nach unten und biegt
+mit einer kleinen Rundung von links in den Nachfolger ein; liegt der
+Nachfolger links vom Auslöser, kommt sie von oben an. Jeder Pfad trägt
+`data-von`/`data-nach`, damit das Überfahren eines Eintrags seine ganze Kette
+hell schaltet (`zlKetteHervorheben()`).
+
+### 2.4 Die Karte
+
+Ein Klick auf einen Eintrag öffnet eine kleine Karte: was die Automatik tut,
+wann, wie sie heute steht — und die drei Dinge, die man damit tun will
+(Bearbeiten, Pausieren, Löschen). Pausieren und Löschen gehen über dieselben
+Funktionen wie in der Liste, samt Rückfrage nach abhängigen Automatiken.
+
+Zwei Fallen stecken in dieser Karte, beide behoben:
+
+1. **Die Karte machte eine zweite Bildlaufleiste.** AdminLTE gibt
+ `main.app-main` `overflow: auto` bei Inhaltshöhe. Wurde die Karte zum
+ Messen kurz ohne Lage eingehängt, stand sie unterhalb aller Bahnen und
+ ragte aus `main` — Chrome zeigte dann dauerhaft eine eigene Leiste.
+ Heute wird sie unsichtbar oben links eingehängt, gemessen und erst dann
+ platziert.
+2. **Die Karte verschwand sofort wieder.** Die neue Leiste machte die
+ Zeitleiste schmaler, der `ResizeObserver` zeichnete neu — und dabei ging
+ die Karte verloren. Heute merkt sich `zeitleisteZeichnen()`, welche Karte
+ offen war, und öffnet sie danach wieder. Außerdem klappt die Karte nach
+ oben, wenn sie unten aus `main` liefe.
+
+### 2.5 Was ein Neuzeichnen auslöst
+
+| Anlass | Folge |
+|---|---|
+| Tag wechseln, Ansicht öffnen | `zeitleisteLaden()` — neue Daten vom Server |
+| Etagenfilter | nur `zeitleisteZeichnen()`, Auswahl in `localStorage` |
+| Breite ändert sich (> 4 px) | `zeitleisteZeichnen()` nach 120 ms |
+| Minutentakt (nur heute, Tab sichtbar) | `zeitleisteLaden()` — „jetzt“-Linie und neue Läufe |
+| nach Speichern/Pausieren/Löschen | `refreshAutomations()` lädt die Zeitleiste mit |
+
+Was sich der Browser merkt: offene Ansicht (Zeitleiste oder Liste),
+Etagenfilter, Scrollstand innerhalb des Tages.
+
+---
+
+## 3. Wo fange ich an, wenn ich …
+
+| Vorhaben | Ort |
+|---|---|
+| … eine Bahn hinzufügen oder umbenennen | `zeitleisteBahnen()` in `restricted/zeitleiste.php`; Farbe in `css/solar.css` (`.zl-bahn-`) |
+| … das Symbol einer Bahn ändern | ebenda — dasselbe Symbol muss auch im Raum-Modal und im Editor stehen (siehe `GERAETE_SYMBOL` in `autoActionFuncs.js`) |
+| … eine neue Art von Lage einführen | `zeitleiste()` (Gruppen-Auswertung + Art bestimmen), dann `zlLage()`/`zlEintrag()` im Browser |
+| … an der Höhe/Dichte schrauben | `ZL_ZEILE`, `ZL_KOPF`, `ZL_MIN_SPUR` in `js/solar/zeitleiste.js` |
+| … verstehen, warum ein Eintrag „nicht dran“ ist | `zlTagPasst()` — und zum Gegenprüfen `tag_passt()` im Runner |