Refine live control and show overview

This commit is contained in:
hnzio committed 2026-10-24 09:45:01 +02:00
1 parent ec596d442e
commit b1474bc33d
16 files changed
+1480 -305

No files matched your search

+16 -6
View File
@@ -425,29 +425,39 @@
## Kompakte Live-Regie 0.5.12
| Kriterium | Nachweis | Stand |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------- |
| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 |
| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 |
| Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | erfüllt am 24.10.2026 |
| Zweizeiliger Kopf gewinnt Inhaltsfläche | Chromium begrenzt die Kopfhöhe bei 670 × 450 und prüft die unveränderte feste Aktionsleiste | erfüllt am 24.10.2026 |
| Lange Kopftitel bleiben kontrolliert | Abschnitt und Folgetitel kürzen einzeilig und öffnen vollständig per Klick, Enter und Escape | erfüllt am 24.10.2026 |
| Regiemenü bleibt sichtbar und erreichbar | Browser prüft Viewportgrenzen, Scrollzugang, Sendungsname und Aktionen bei 1280, 670 und 390 Pixeln | erfüllt am 24.10.2026 |
| Menü schließt zugänglich | Escape und Außenklick schließen das Popover und führen den Fokus zum Auslöser zurück | erfüllt am 24.10.2026 |
| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 |
| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 |
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 |
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 |
| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 |
| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 |
| Zurück ist reine Betrachtungsnavigation | Browser prüft unverändertes aktuelles Piece, Countdown und Ausgabe sowie gesperrte Fortschrittsaktionen | erfüllt am 24.10.2026 |
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 |
| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 |
| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 |
| Liste und Kalender nutzen dieselben Daten | Chromium wechselt zwischen beiden Ansichten; Sendungslinks bleiben identisch erreichbar | erfüllt am 24.10.2026 |
| Kalenderpräferenz bleibt am Konto | Service- und Browsertest prüfen Standardwert, Speicherung und Reload | erfüllt am 24.10.2026 |
| Monat und Berliner Tage sind korrekt | Unit-Tests prüfen Monatsgrenzen und UTC-Zuordnung; Browser prüft Monatswechsel und heutigen Tag | erfüllt am 24.10.2026 |
| Mehrere und terminlose Sendungen bleiben sichtbar | Desktopkalender und mobile Agenda zeigen zwei Termine am Tag; eigener Bereich zeigt offenen Termin | erfüllt am 24.10.2026 |
## Ausgeführte Prüfungen 0.5.12
- `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 40 Tests in 7 Dateien erfolgreich
- gezielter Live-Regie-Lauf: 1 von 1 Chromium-Test erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich
- Screenshots der Live-Regie bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln
einschließlich mobilem Ablauf-Sheet visuell kontrolliert
- `npm test`: 44 Tests in 8 Dateien erfolgreich
- gezielte Kalender- und Live-Regie-Läufe: 2 von 2 Chromium-Tests erfolgreich
- `npm run test:e2e`: 17 von 17 Chromium-Browsertests erfolgreich
- Browserprüfungen der Live-Regie bei 1440 × 900, 900 × 450, 670 × 450 und
390 × 700 Pixeln sowie des Kalenders bei 1440 × 1000 und 390 × 844 Pixeln
erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich
+30 -7
View File
@@ -192,6 +192,13 @@ Sendungsvorlagen und ihre Abschnitte sind eigene Tabellen. Beim Anlegen werden
die Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern.
Die Sendungsübersicht leitet Liste, Desktop-Monatsraster und mobile Monatsagenda
aus derselben `listShows()`-Abfrage ab. UTC-Zeitpunkte werden erst für Gruppierung
und Anzeige nach `Europe/Berlin` in zivile Kalendertage übersetzt. Sendungen ohne
Startzeit bleiben in einem eigenen Bereich sichtbar. `users.show_overview_view`
speichert ausschließlich die kontobezogene Darstellungspräferenz; der gewählte
Monat bleibt URL-Zustand und verändert keine Sendungsdaten.
Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs
@@ -241,10 +248,13 @@ Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen
erst nach einer ausdrücklichen Snapshot-Aktualisierung.
Lokale Betrachtungsnavigation, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Zurück und die Auswahl einer Ablaufzeile wechseln nur
den im Browser betrachteten Verwendungssnapshot; tatsächliches Live-Piece,
Status, Timer und Ausgabe bleiben serverseitig unverändert. Die öffentliche
Projektion liest ausschließlich den ausdrücklich präsentierten
Verwendungssnapshot; spätere Änderungen am Original erscheinen erst nach einer
ausdrücklichen Snapshot-Aktualisierung.
Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost
@@ -279,6 +289,10 @@ die untere Hauptnavigation die Seitenleiste vollständig. Auf breiten Ansichten
stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben.
Die Sendungsübersicht schaltet über zwei gleichwertige Formularaktionen zwischen
Liste und Kalender um. Auf breiten Ansichten erscheint ein siebenspaltiges
Monatsraster ohne Termin-Drag-and-drop; bis `52rem` ersetzt es eine lineare,
touchfreundliche Agenda desselben Monats.
Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation.
@@ -292,9 +306,18 @@ Sheet; eine geringe Desktophöhe verdichtet zusätzlich Abstände und Leisten,
ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints
wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung
eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand
aufgehoben. Dieser Layoutmodus ändert weder die Live-Actions noch die getrennte
öffentliche `/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige
Verwendung gebunden und werden nicht zwischen Pieces übertragen.
aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst
Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten
Zustand. Die öffentliche `/heinzlive`-Projektion bleibt unverändert. Lokale
Lesepositionen sind an die jeweilige Verwendung gebunden und werden nicht
zwischen Pieces übertragen.
Der Live-Kopf besteht aus zwei regulären Rasterzeilen. Abschnitt und folgendes
Piece sind einzeilig gekürzte, fokussierbare Schaltflächen, die ihren vollständigen
Text lokal aufklappen. Das Regiemenü wird bei Bedarf als fest am Viewport
positionierter Dialog neben dem Kopf gerendert. Dadurch unterliegt es weder dem
Overflow der Regiefläche noch dem durch Glasfilter erzeugten Stacking Context;
seine maximale Höhe bleibt an den Viewport gebunden und intern scrollbar.
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
+31 -5
View File
@@ -371,8 +371,34 @@ breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet. Die Höhe
steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße
und Gesamtoberfläche werden nicht skaliert.
Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl,
Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte
serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein
Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche
Präsentationsaction verändert die getrennte öffentliche Ausgabe.
Der Kopf verwendet genau zwei kompakte Informationszeilen. Lange Kontexttitel
bleiben im Ruhezustand einzeilig und werden auf bewusste Klick- oder
Tastaturaktion lokal vollständig sichtbar. Das Regiemenü ist kein verschachteltes
`details` innerhalb der Glasfläche, sondern ein explizit gesteuertes,
viewportgebundenes Popover. So bleiben Z-Index, Maximalhöhe, Escape,
Außenklick und Fokus-Rückkehr unabhängig von Overflow und Filter-Stacking-
Contexts kontrollierbar.
Diese Darstellung ist keine neue Live-Zustandsgrenze. Zurück und die direkte
Auswahl im Ablauf sind reine lokale Betrachtungsnavigation. Sie verändern weder
aktuelles Live-Piece noch Status, Zeitintervalle, Abschnittstimer oder Ausgabe.
Während ein anderes Piece betrachtet wird, bleiben Fortschrittsaktionen
gesperrt; Präsentieren darf dagegen weiterhin ausdrücklich genau das betrachtete
Piece ausgeben. Start, Erledigen, Überspringen, Reserveeinfügung und Präsentation
bleiben serverseitige Actions mit ihren bisherigen Revisions- und
Idempotenzregeln.
## D-025: Gemeinsame Sendungsdaten für Liste und Kalender
Listenansicht, Desktop-Monatsraster und mobile Monatsagenda werden aus derselben
kompakten Sendungsliste abgeleitet. Es gibt kein zweites Kalenderdatenmodell und
keine Bibliothek für Terminverwaltung. UTC-Zeitpunkte werden mit
`Europe/Berlin` einem zivilen Tag zugeordnet; der ausgewählte Monat bleibt als
URL-Parameter navigierbar. Mehrere Sendungen eines Tages werden vollständig und
chronologisch gezeigt, Sendungen ohne Startzeit separat.
Die Präferenz `list` oder `calendar` gehört zum angemeldeten Konto und wird als
kleines geprüftes Feld an `users` gespeichert. Sie ist weder Bestandteil einer
Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
bestehende serverseitig validierte Sendungsbearbeitung erfolgt.
+49 -21
View File
@@ -1,29 +1,33 @@
# Release Notes
## 0.5.12 - Kompakte Live-Regie
## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026
Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Das
aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die
wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche
heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt.
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Die
Sendungsübersicht erhält zusätzlich eine kontobezogen gespeicherte Listen- und
Kalenderansicht. Die öffentliche heinzlive-Ausgabe und die gespeicherten
Livezustände bleiben technisch getrennt.
### Highlights
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den
tatsächlich präsentierten Ausgabezustand.
- Zwei kompakte Kopfzeilen halten Abschnitt, „Danach“, Menü, Ausgabe,
Abschnittscountdown und Ablauf gleichzeitig erreichbar. Der Sendungsname ist
in das Menü gewandert.
- Lange Abschnitts- und Folgetitel bleiben einzeilig gekürzt und lassen sich per
Klick oder Tastatur vollständig aufklappen, ohne benachbarte Bedienelemente zu
überlagern.
- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
- Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der
verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren.
- Die feste Fußleiste bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und
„Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende
eindeutig benannt.
- Die feste Fußleiste ist auf „Zurück“, „Präsentieren“, „Überspringen“ und
„Erledigen & weiter“ reduziert. Das nächste Piece steht in der Kopfzeile, das
Leeren der Präsentation direkt am Ausgabezustand.
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet,
während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und
der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber.
@@ -36,21 +40,25 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt
werden nur bei Bedarf geöffnet.
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
gespeichert. Texte werden nicht automatisch verkleinert.
- Piece-Position, Piece-Titel und Zeit stehen in einer gemeinsamen kompakten
Kopfzeile; dadurch beginnt die Einleitung weiter oben.
### Ablauf und Liveverhalten
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
das aktive Piece bleibt in der Ablaufspalte sichtbar.
- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch
textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags.
- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
ausgegebenes Piece getrennt. Jeder Eintrag lässt sich zur Betrachtung wählen,
einschließlich des ersten Eintrags.
- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
eine kompakte Aktion am jeweiligen Eintrag erreichbar.
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts.
Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche
Ausgabe.
- „Zurück“ und direkte Ablaufwahl verändern weder aktuelles Live-Piece noch
Erledigungsstatus, Timer oder Ausgabe. Bei einem nur betrachteten früheren
Piece bleiben Überspringen und Erledigen gesperrt. Nur „Präsentieren“ oder
„Präsentation leeren“ verändert die öffentliche Ausgabe.
- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
verwenden unverändert die vorhandenen persistenten Zustände.
- „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich
@@ -60,21 +68,41 @@ heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt
- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar.
- Das Regiemenü erscheint als viewportgebundenes Popover sicher über den
Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt
mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück.
### Sendungsübersicht
- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer
Monatsansicht um; die Auswahl bleibt am angemeldeten Konto gespeichert.
- Auf dem Desktop zeigt ein vollständiges Montag-bis-Sonntag-Raster alle Termine
des Monats. Mehrere Sendungen pro Tag bleiben vollständig sichtbar und nach
Uhrzeit sortiert.
- Auf dem Smartphone wird derselbe Monat als chronologische Agenda dargestellt.
Vorheriger Monat, nächster Monat und der Sprung zu heute bleiben erreichbar.
- Die Gruppierung verwendet `Europe/Berlin`. Sendungen ohne Startzeit stehen in
einem eigenen Bereich und verschwinden weder aus Liste noch Kalender.
- Der Kalender ist eine Navigationsansicht. Termine werden weiterhin nur über
die bestehende Sendungsbearbeitung geändert; Termin-Drag-and-drop wurde nicht
ergänzt.
### Update und Kompatibilität
- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der
Konfiguration.
- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
- Es ist keine Änderung der Konfiguration erforderlich.
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
Präsentationssnapshots bleiben erhalten.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
### Prüfung
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit
langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft;
die erzeugten Screenshots wurden visuell kontrolliert.
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
mobilem Ablauf-Sheet geprüft; der Kalender zusätzlich bei 1440 × 1000 und
390 × 844 Pixeln.
- Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung.
+25 -9
View File
@@ -7,14 +7,19 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen.
## Kompakte Live-Regie 0.5.12
## Live-Regie und Sendungskalender 0.5.12
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece,
Präsentation, Überspringen und kontextgerechtem Erledigen
- zweizeilige feste Kopfzeile: Abschnitt und „Danach“ teilen sich die erste
Zeile mit dem Menü; Ausgabe, Abschnittscountdown und Ablaufschalter bilden die
zweite Zeile. Der Sendungsname steht platzsparend im Menü
- lange Abschnitts- und Folgetitel sind regulär einzeilig gekürzt und lassen
sich per Klick oder Tastatur vollständig aufklappen
- feste, auf vier Kernaktionen reduzierte Fußleiste mit Zurück, Präsentieren,
Überspringen und kontextgerechtem Erledigen; das Leeren der Präsentation liegt
direkt beim Ausgabezustand
- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen
@@ -22,22 +27,33 @@ Etappe 6 wurde nicht begonnen.
verfügbaren Höhe verdichtet, ohne Text oder Gesamtoberfläche zu skalieren
- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet
als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter
Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, lokaler
Betrachtung jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
erreichbar, dauerhafte Sortierpfeile entfallen
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
beginnt oben statt an der Position des vorherigen Textes
- Piece-Position, Titel und Zeit teilen sich einen kompakten Piece-Kopf, ohne die
einstellbare Moderationsschrift zu verkleinern
- das Regiemenü liegt als viewportgebundenes Popover sicher über den Glasflächen,
bleibt scrollbar erreichbar und schließt per Escape oder Außenklick mit
Fokus-Rückkehr zum Auslöser
- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
leere Moderationsbereiche werden nicht dargestellt
- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation
verwenden unverändert ihre getrennten persistenten Zustände
- Zurück und direkte Ablaufwahl verändern weder tatsächliches Live-Piece noch
Status, Timer oder öffentliche Ausgabe; Überspringen und Erledigen bleiben bei
einem nur betrachteten früheren Piece gesperrt
- Liste und Kalender teilen dieselben Sendungsdaten; der Desktop zeigt ein
vollständiges Monatsraster, das Smartphone eine chronologische Monatsagenda
- Monatswechsel, heutiger Tag, mehrere Termine pro Tag und Sendungen ohne Termin
bleiben sichtbar; die bevorzugte Listen- oder Kalenderansicht wird je Konto
gespeichert
- die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem
Live-Stand statt während eines laufenden Pieces
Etappe 6 bleibt unverändert unbegonnen.
Die lokale Prüfmatrix umfasst 40 Verhaltenstests und 16 Chromium-Abläufe.
Die lokale Prüfmatrix umfasst 44 Verhaltenstests und 17 Chromium-Abläufe.
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
bleiben mangels verfügbarer Umgebung offen.
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE users
ADD COLUMN show_overview_view TEXT NOT NULL DEFAULT 'list'
CHECK (show_overview_view IN ('list', 'calendar'));
+562 -109
View File
@@ -1002,11 +1002,9 @@ textarea:focus {
display: grid;
position: relative;
z-index: 60;
grid-template-columns: minmax(12rem, 1fr) auto minmax(8rem, 0.55fr) auto;
align-items: center;
gap: 0.65rem;
gap: 0.4rem;
min-width: 0;
padding: 0.55rem 0.65rem;
padding: 0.45rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line));
border-radius: 0.9rem;
background: var(--glass);
@@ -1014,22 +1012,41 @@ textarea:focus {
backdrop-filter: blur(20px) saturate(1.08);
}
.live-section-context,
.live-output-state {
.live-context-row,
.live-status-row {
display: grid;
min-width: 0;
align-items: center;
}
.live-context-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
}
.live-status-row {
grid-template-columns: minmax(12rem, 1fr) auto auto;
gap: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
}
.live-output-state,
.live-context-summary,
.live-next-inline {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
}
.live-section-context > div,
.live-output-state > div {
display: grid;
min-width: 0;
}
.live-section-context small,
.live-output-state small,
.live-next-inline small,
.section-countdown span {
color: var(--text-faint);
font-size: 0.59rem;
@@ -1038,7 +1055,6 @@ textarea:focus {
text-transform: uppercase;
}
.live-section-context strong,
.live-output-state strong {
overflow: hidden;
font-size: 0.75rem;
@@ -1046,6 +1062,67 @@ textarea:focus {
white-space: nowrap;
}
.live-context-summary {
gap: 0.45rem;
}
.live-context-title {
display: block;
min-width: 0;
max-width: min(34rem, 42vw);
padding: 0.28rem 0.34rem;
border: 0;
border-radius: 0.42rem;
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 760;
text-align: left;
}
.live-context-title span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-context-title:hover,
.live-context-title:focus-visible,
.live-context-title.expanded {
background: var(--panel-soft);
}
.live-context-title:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.live-context-title.expanded span {
overflow: visible;
white-space: normal;
}
.live-next-inline {
flex: 1 1 20rem;
gap: 0.2rem;
padding-left: 0.45rem;
border-left: 1px solid var(--line);
}
.live-next-inline small {
flex: 0 0 auto;
letter-spacing: 0;
text-transform: none;
}
.live-next-inline .live-context-title {
flex: 1 1 auto;
font-size: 0.7rem;
}
.live-pulse {
width: 0.48rem;
height: 0.48rem;
@@ -1089,23 +1166,69 @@ textarea:focus {
background: var(--text-faint);
}
.live-output-state.active > span {
.live-output-control.active .live-output-state > span {
background: var(--accent);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent);
}
.live-mobile-run-button {
display: none;
.live-output-control {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
}
.live-menu {
position: relative;
z-index: 40;
width: fit-content;
justify-self: end;
.live-output-control .live-output-state {
min-width: 0;
}
.live-menu > summary {
.live-output-control form {
display: flex;
flex: 0 0 auto;
}
.live-output-control button {
padding: 0.32rem 0.48rem;
font-size: 0.62rem;
}
.live-run-button {
display: flex;
min-height: 2rem;
align-items: center;
gap: 0.38rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.67rem;
font-weight: 740;
}
.live-run-button:hover,
.live-run-button.active {
border-color: color-mix(in srgb, var(--accent) 58%, var(--line));
background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft));
color: var(--accent-strong);
}
.live-run-button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.live-run-button .app-icon:last-child {
transition: transform 140ms ease;
}
.live-run-button.active .app-icon:last-child {
transform: rotate(180deg);
}
.live-menu-trigger {
width: fit-content;
padding: 0.48rem 0.65rem;
border: 1px solid var(--line);
@@ -1114,17 +1237,27 @@ textarea:focus {
cursor: pointer;
font-size: 0.7rem;
font-weight: 750;
list-style: none;
}
.live-menu-trigger:hover,
.live-menu-trigger[aria-expanded='true'] {
border-color: color-mix(in srgb, var(--accent) 58%, var(--line));
background: color-mix(in srgb, var(--accent) 9%, var(--panel-soft));
}
.live-menu-trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.live-menu-popover {
position: absolute;
z-index: 50;
top: calc(100% + 0.4rem);
right: 0;
position: fixed;
z-index: 200;
top: max(3.5rem, calc(env(safe-area-inset-top) + 3rem));
right: max(0.65rem, env(safe-area-inset-right));
display: grid;
width: min(22rem, calc(100vw - 1.3rem));
max-height: calc(100dvh - 5rem);
max-height: calc(100dvh - 4rem - env(safe-area-inset-top));
overflow-y: auto;
padding: 0.7rem;
border: 1px solid var(--line-strong);
@@ -1132,6 +1265,29 @@ textarea:focus {
background: var(--glass-strong);
box-shadow: var(--shadow-large);
backdrop-filter: blur(24px);
overscroll-behavior: contain;
}
.live-menu-show small,
.live-menu-show strong {
display: block;
}
.live-menu-show small {
color: var(--text-faint);
font-size: 0.6rem;
font-weight: 720;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.live-menu-popover .live-menu-show > strong {
margin-top: 0.15rem;
color: var(--text);
font-size: 0.78rem;
letter-spacing: normal;
overflow-wrap: anywhere;
text-transform: none;
}
.live-menu-popover section {
@@ -1231,39 +1387,42 @@ textarea:focus {
}
.live-piece-heading {
padding-bottom: 0.85rem;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 0.35rem 0.65rem;
padding-bottom: 0.5rem;
}
.live-kicker {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
.live-piece-position {
color: var(--accent);
font-size: 0.65rem;
font-weight: 750;
letter-spacing: 0.055em;
text-transform: uppercase;
white-space: nowrap;
}
.live-kicker strong {
.live-piece-time {
color: var(--text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.live-current h1 {
max-width: 30ch;
margin: 0.45rem 0 0;
font-size: clamp(1.45rem, 2.7vw, 2.55rem);
max-width: none;
margin: 0;
font-size: clamp(1.2rem, 2.2vw, 1.9rem);
font-weight: 690;
letter-spacing: -0.045em;
line-height: 1.04;
letter-spacing: -0.035em;
line-height: 1.08;
overflow-wrap: anywhere;
}
.live-script-section {
max-width: 58rem;
padding: 1rem 0;
padding: 0.75rem 0;
border-top: 1px solid var(--line);
}
@@ -1510,6 +1669,14 @@ textarea:focus {
background: color-mix(in srgb, var(--gold) 10%, var(--panel));
}
.live-run-item.selected .live-run-select {
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.live-run-item.presented {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent);
}
.live-run-item.done {
opacity: 0.68;
}
@@ -1519,7 +1686,7 @@ textarea:focus {
opacity: 0.62;
}
.live-run-item > form:first-child {
.live-run-item > .live-run-select {
min-width: 0;
}
@@ -1602,6 +1769,15 @@ textarea:focus {
opacity: 1;
}
.live-run-select:not(:disabled):hover {
background: color-mix(in srgb, var(--accent) 10%, var(--panel-soft));
}
.live-run-select:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.live-run-select strong,
.live-reserve-item strong {
font-size: 0.67rem;
@@ -1673,9 +1849,8 @@ textarea:focus {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding: 0.55rem 0.65rem;
justify-content: flex-end;
padding: 0.4rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
border-radius: 0.9rem;
background: var(--glass-strong);
@@ -1683,30 +1858,12 @@ textarea:focus {
backdrop-filter: blur(22px) saturate(1.08);
}
.live-next-summary {
display: grid;
min-width: 0;
}
.live-next-summary span {
color: var(--accent);
font-size: 0.58rem;
font-weight: 760;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.live-next-summary strong {
overflow: hidden;
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-footer-actions {
display: flex;
display: grid;
width: min(100%, 42rem);
align-items: center;
justify-content: flex-end;
grid-template-columns: repeat(4, minmax(0, auto));
justify-content: end;
gap: 0.4rem;
}
@@ -1715,6 +1872,7 @@ textarea:focus {
}
.live-footer-actions button {
min-height: 2.2rem;
white-space: nowrap;
}
@@ -1748,7 +1906,7 @@ textarea:focus {
}
.live-piece-heading {
padding-bottom: 0.55rem;
padding-bottom: 0.4rem;
}
.live-script-section {
@@ -1764,50 +1922,54 @@ textarea:focus {
}
.live-command-header {
grid-template-areas:
'context menu'
'countdown output'
'run run';
gap: 0.2rem;
padding: 0.32rem 0.4rem;
}
.live-context-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.4rem;
padding: 0.45rem;
}
.live-section-context {
grid-area: context;
.live-context-summary {
gap: 0.25rem;
}
.section-countdown {
grid-area: countdown;
min-width: 0;
text-align: left;
.live-context-title {
max-width: 34vw;
padding-inline: 0.25rem;
}
.live-output-state {
grid-area: output;
min-width: 0;
.live-next-inline {
gap: 0.12rem;
padding-left: 0.25rem;
}
.live-status-row {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.3rem;
padding-top: 0.2rem;
}
.live-output-state strong {
max-width: 8rem;
}
.live-output-control {
gap: 0.3rem;
}
.live-output-control button {
padding-inline: 0.35rem;
}
.live-menu {
grid-area: menu;
.section-countdown {
min-width: 5.5rem;
padding: 0.28rem 0.4rem;
}
.live-mobile-run-button {
display: block;
grid-area: run;
width: 100%;
padding: 0.42rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.67rem;
font-weight: 740;
}
.live-desktop-rail-toggle {
display: none;
.live-run-button {
padding-inline: 0.45rem;
}
.live-workspace,
@@ -1855,7 +2017,11 @@ textarea:focus {
}
.live-current h1 {
font-size: clamp(1.35rem, 7vw, 2rem);
font-size: clamp(1.15rem, 5.5vw, 1.65rem);
}
.live-piece-heading {
gap: 0.3rem 0.45rem;
}
.live-support-actions {
@@ -1869,18 +2035,13 @@ textarea:focus {
}
.live-action-bar {
display: grid;
gap: 0.4rem;
padding: 0.45rem;
}
.live-next-summary strong {
font-size: 0.68rem;
padding: 0.35rem;
}
.live-footer-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
grid-template-columns: repeat(4, minmax(0, 1fr));
justify-content: stretch;
gap: 0.3rem;
}
@@ -1897,16 +2058,41 @@ textarea:focus {
font-size: 0.64rem;
}
.live-clear-form {
grid-column: 1 / -1;
}
.live-finished-label {
grid-column: 1 / -1;
text-align: center;
}
}
@media (max-width: 23rem) {
.live-context-summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
}
.live-context-title {
max-width: none;
}
.live-next-inline {
grid-column: 2;
padding-left: 0;
border-left: 0;
}
.live-status-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.live-output-control {
grid-column: 1 / -1;
}
.live-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.heinzlive-output {
min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem);
@@ -3941,6 +4127,42 @@ textarea:focus {
max-width: 78rem;
}
.shows-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
}
.show-view-toggle {
display: flex;
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
}
.show-view-toggle button {
display: flex;
min-height: 2.2rem;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.65rem;
border: 0;
border-radius: 0.58rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.7rem;
font-weight: 740;
}
.show-view-toggle button[aria-pressed='true'] {
background: var(--panel);
box-shadow: var(--shadow-soft);
color: var(--gold-strong);
}
.show-create-panel,
.show-library,
.show-settings {
@@ -4157,6 +4379,237 @@ textarea:focus {
font-size: 0.78rem;
}
.show-month-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.7rem;
}
.show-month-toolbar h3 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.show-month-navigation {
display: flex;
align-items: center;
gap: 0.3rem;
}
.show-month-navigation a {
display: grid;
min-width: 2.2rem;
min-height: 2.2rem;
place-items: center;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--panel-soft);
font-size: 0.68rem;
font-weight: 730;
}
.show-calendar-weekdays,
.show-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.show-calendar-weekdays {
border: 1px solid var(--line);
border-bottom: 0;
border-radius: 0.8rem 0.8rem 0 0;
background: var(--panel-soft);
}
.show-calendar-weekdays span {
padding: 0.45rem;
color: var(--text-faint);
font-size: 0.62rem;
font-weight: 750;
text-align: center;
}
.show-calendar-grid {
overflow: hidden;
border-top: 1px solid var(--line);
border-left: 1px solid var(--line);
border-radius: 0 0 0.8rem 0.8rem;
}
.show-calendar-day {
min-width: 0;
min-height: 7.5rem;
padding: 0.4rem;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel-soft) 75%, transparent);
}
.show-calendar-day.outside {
min-height: 3.25rem;
background: color-mix(in srgb, var(--panel-soft) 32%, transparent);
color: var(--text-faint);
}
.show-calendar-day > strong {
display: grid;
width: 1.55rem;
height: 1.55rem;
place-items: center;
border-radius: 50%;
font-size: 0.68rem;
}
.show-calendar-day.today > strong {
background: var(--gold);
color: #152238;
}
.show-calendar-events {
display: grid;
gap: 0.3rem;
margin-top: 0.35rem;
}
.show-calendar-event {
display: grid;
min-width: 0;
gap: 0.08rem;
padding: 0.38rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--panel);
}
.show-calendar-event:hover {
border-color: var(--accent);
background: var(--panel-strong);
}
.show-calendar-event.live,
.show-calendar-event.ready {
border-left: 0.2rem solid var(--accent);
}
.show-calendar-event time,
.show-calendar-event small {
color: var(--text-faint);
font-size: 0.54rem;
}
.show-calendar-event span {
font-size: 0.65rem;
font-weight: 730;
overflow-wrap: anywhere;
}
.show-calendar-event small {
text-transform: uppercase;
}
.show-agenda {
display: none;
}
.show-undated {
margin-top: 0.8rem;
}
@media (max-width: 52rem) {
.shows-heading {
align-items: stretch;
flex-direction: column;
}
.show-view-toggle,
.show-view-toggle button {
width: 100%;
}
.show-view-toggle button {
justify-content: center;
}
.show-calendar {
display: none;
}
.show-agenda {
display: grid;
gap: 0.65rem;
}
.show-agenda-day {
display: grid;
grid-template-columns: minmax(7rem, 0.35fr) minmax(0, 1fr);
gap: 0.65rem;
padding: 0.65rem 0;
border-top: 1px solid var(--line);
}
.show-agenda-day h3 {
margin: 0;
font-size: 0.78rem;
text-transform: capitalize;
}
.show-agenda-day > div {
display: grid;
gap: 0.35rem;
}
.show-agenda-event {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.6rem;
padding: 0.65rem;
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--panel-soft);
}
.show-agenda-event time {
color: var(--accent-text);
font-size: 0.7rem;
font-weight: 740;
}
.show-agenda-event span,
.show-agenda-event small {
display: grid;
min-width: 0;
}
.show-agenda-event strong {
overflow-wrap: anywhere;
}
.show-agenda-event small {
margin-top: 0.15rem;
color: var(--text-faint);
font-size: 0.58rem;
text-transform: uppercase;
}
}
@media (max-width: 32rem) {
.show-month-toolbar {
align-items: stretch;
flex-direction: column;
}
.show-month-navigation {
justify-content: space-between;
}
.show-agenda-day {
grid-template-columns: minmax(0, 1fr);
}
}
.show-settings {
margin-bottom: 0.75rem;
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
berlinDateKey,
buildCalendarDays,
formatMonthLabel,
normalizeMonthKey,
shiftMonth
} from './calendar.js';
describe('show calendar', () => {
it('groups UTC instants by their Berlin civil date', () => {
expect(berlinDateKey('2026-09-30T22:30:00.000Z')).toBe('2026-10-01');
expect(berlinDateKey('2026-12-31T23:30:00.000Z')).toBe('2027-01-01');
});
it('builds a Monday-first six-week month without dropping boundary days', () => {
const days = buildCalendarDays('2026-10', '2026-10-24');
expect(days).toHaveLength(42);
expect(days[0]).toMatchObject({ date: '2026-09-28', inSelectedMonth: false });
expect(days.find((day) => day.date === '2026-10-24')?.isToday).toBe(true);
expect(days.at(-1)?.date).toBe('2026-11-08');
});
it('moves across year boundaries and validates month keys', () => {
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
expect(normalizeMonthKey('2026-13', '2026-10')).toBe('2026-10');
expect(normalizeMonthKey('2026-02', '2026-10')).toBe('2026-02');
expect(formatMonthLabel('2026-10')).toBe('Oktober 2026');
});
});
+75
View File
@@ -0,0 +1,75 @@
export const BERLIN_TIME_ZONE = 'Europe/Berlin';
export type CalendarDay = {
date: string;
dayNumber: number;
inSelectedMonth: boolean;
isToday: boolean;
};
const berlinDateFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
function padded(value: number) {
return String(value).padStart(2, '0');
}
function dateParts(value: string | Date) {
const parts = berlinDateFormatter.formatToParts(
typeof value === 'string' ? new Date(value) : value
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value);
return { year: read('year'), month: read('month'), day: read('day') };
}
export function berlinDateKey(value: string | Date) {
const parts = dateParts(value);
return `${parts.year}-${padded(parts.month)}-${padded(parts.day)}`;
}
export function berlinMonthKey(value: string | Date) {
return berlinDateKey(value).slice(0, 7);
}
export function normalizeMonthKey(value: string | null | undefined, fallback: string) {
if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return fallback;
return value;
}
export function shiftMonth(monthKey: string, delta: number) {
const [year, month] = monthKey.split('-').map(Number);
const shifted = new Date(Date.UTC(year, month - 1 + delta, 1));
return `${shifted.getUTCFullYear()}-${padded(shifted.getUTCMonth() + 1)}`;
}
export function formatMonthLabel(monthKey: string) {
const [year, month] = monthKey.split('-').map(Number);
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
month: 'long',
year: 'numeric'
}).format(new Date(Date.UTC(year, month - 1, 1)));
}
export function buildCalendarDays(monthKey: string, todayKey: string): CalendarDay[] {
const [year, month] = monthKey.split('-').map(Number);
const first = new Date(Date.UTC(year, month - 1, 1));
const mondayOffset = (first.getUTCDay() + 6) % 7;
const start = new Date(Date.UTC(year, month - 1, 1 - mondayOffset));
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + index);
const dateKey = `${date.getUTCFullYear()}-${padded(date.getUTCMonth() + 1)}-${padded(date.getUTCDate())}`;
return {
date: dateKey,
dayNumber: date.getUTCDate(),
inSelectedMonth: dateKey.startsWith(monthKey),
isToday: dateKey === todayKey
};
});
}
+15
View File
@@ -70,6 +70,21 @@ afterEach(async () => {
});
describe('authentication', () => {
it('stores the show overview preference per user', async () => {
const { createFirstAdmin, getShowOverviewView, setShowOverviewView } =
await import('./auth.js');
const user = await createFirstAdmin({
setupToken: 'a'.repeat(32),
email: 'calendar@example.test',
displayName: 'Kalender Test',
password: 'a-valid-password-123'
});
expect(getShowOverviewView(user.id)).toBe('list');
expect(setShowOverviewView(user.id, 'calendar')).toBe('calendar');
expect(getShowOverviewView(user.id)).toBe('calendar');
expect(() => setShowOverviewView(user.id, 'invalid')).toThrow('INVALID_VIEW');
});
it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort';
+18
View File
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
} as const;
export type Role = 'barkeeper' | 'thekencrew';
export type ShowOverviewView = 'list' | 'calendar';
export type AuthUser = {
id: string;
email: string;
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function getShowOverviewView(userId: string): ShowOverviewView {
const row = getDatabase()
.prepare('SELECT show_overview_view FROM users WHERE id = ?')
.get(userId) as { show_overview_view: ShowOverviewView } | undefined;
if (!row) throw new Error('NOT_FOUND');
return row.show_overview_view;
}
export function setShowOverviewView(userId: string, view: string): ShowOverviewView {
if (view !== 'list' && view !== 'calendar') throw new Error('INVALID_VIEW');
const result = getDatabase()
.prepare('UPDATE users SET show_overview_view = ?, updated_at_utc = ? WHERE id = ?')
.run(view, nowUtc(), userId);
if (!result.changes) throw new Error('NOT_FOUND');
return view;
}
export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close();
expect(counts).toEqual({
migrations: 11,
migrations: 12,
pieces: 6,
shows: 2,
usages: 5,
+242 -102
View File
@@ -11,23 +11,38 @@
let promptSize = $state(1.55);
let showRail = $state(true);
let mobileRailOpen = $state(false);
let isMobile = $state(false);
let showTimes = $state(true);
let menuOpen = $state(false);
let expandedContext = $state<'section' | 'next' | null>(null);
let popupMessage = $state('');
let now = $state(Date.now());
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let loadedRevision = $state(untrack(() => data.live.liveRevision));
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
let pieceScroller = $state<HTMLElement>();
let railScroller = $state<HTMLElement>();
let liveRail = $state<HTMLElement>();
let mobileRunButton = $state<HTMLButtonElement>();
let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>();
let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
let viewedUsage = $derived(
allUsages.find((usage) => usage.id === viewedUsageId) ?? data.live.current
);
let currentPosition = $derived(
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0
);
let viewedPosition = $derived(
viewedUsage ? allUsages.findIndex((usage) => usage.id === viewedUsage?.id) + 1 : 0
);
let previousUsage = $derived(viewedPosition > 1 ? allUsages[viewedPosition - 2] : null);
let viewingLiveCurrent = $derived(viewedUsage?.id === data.live.current?.id);
let runExpanded = $derived(isMobile ? mobileRailOpen : showRail);
let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
);
@@ -51,10 +66,19 @@
data.live.completedCount === data.live.totalCount
)
);
let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')
);
let nextTitle = $derived(
data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
);
let viewedActualSeconds = $derived(
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
);
let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc
@@ -80,6 +104,7 @@
loadedRevision = data.live.liveRevision;
loadedAt = Date.now();
if (changedPiece) {
viewedUsageId = nextCurrentId;
void tick().then(() => {
if (pieceScroller)
pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0;
@@ -121,19 +146,36 @@
localStorage.removeItem(key);
}
const mobileBreakpoint = matchMedia('(max-width: 46rem)');
isMobile = mobileBreakpoint.matches;
const handleBreakpointChange = (event: MediaQueryListEvent) => {
isMobile = event.matches;
if (!event.matches) void closeMobileRail(false);
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (menuOpen && !liveMenuPopover?.contains(target) && !liveMenuTrigger?.contains(target)) {
void closeLiveMenu();
}
if (
expandedContext &&
!(target instanceof Element && target.closest('.live-context-title'))
) {
expandedContext = null;
}
};
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
document.addEventListener('click', handleDocumentClick);
const interval = setInterval(() => (now = Date.now()), 1000);
return () => {
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
document.removeEventListener('click', handleDocumentClick);
clearInterval(interval);
};
});
function persistSettings() {
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop;
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({
@@ -155,7 +197,31 @@
persistSettings();
}
function toggleRunPanel() {
void closeLiveMenu(false);
if (isMobile) {
if (mobileRailOpen) void closeMobileRail();
else void openMobileRail();
return;
}
toggleDesktopRail();
}
function viewUsage(usageId: string) {
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
viewedUsageId = usageId;
void tick().then(() => {
if (pieceScroller) pieceScroller.scrollTop = scrollPositions[usageId] ?? 0;
});
void closeMobileRail();
}
function viewPreviousUsage() {
if (previousUsage) viewUsage(previousUsage.id);
}
async function openMobileRail() {
await closeLiveMenu(false);
mobileRailOpen = true;
await tick();
railCloseButton?.focus();
@@ -169,12 +235,43 @@
}
function handleWindowKeydown(event: KeyboardEvent) {
if (mobileRailOpen && event.key === 'Escape') {
if (event.key !== 'Escape') return;
if (menuOpen) {
event.preventDefault();
void closeLiveMenu();
} else if (expandedContext) {
event.preventDefault();
expandedContext = null;
} else if (mobileRailOpen) {
event.preventDefault();
void closeMobileRail();
}
}
async function toggleLiveMenu() {
if (menuOpen) {
await closeLiveMenu();
return;
}
menuOpen = true;
expandedContext = null;
await tick();
liveMenuPopover
?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
?.focus({ preventScroll: true });
}
async function closeLiveMenu(restoreFocus = true) {
if (!menuOpen) return;
menuOpen = false;
await tick();
if (restoreFocus) liveMenuTrigger?.focus({ preventScroll: true });
}
function toggleContext(name: 'section' | 'next') {
expandedContext = expandedContext === name ? null : name;
}
function trapRailFocus(event: KeyboardEvent) {
if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return;
const focusable = Array.from(
@@ -220,7 +317,7 @@
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
void closeLiveMenu();
const submittedFromRail = Boolean(formElement.closest('.live-rail'));
const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
const command = formElement.elements.namedItem('commandId');
@@ -238,8 +335,8 @@
};
};
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
const enhanceOutput: SubmitFunction = ({ submitter }) => {
void closeLiveMenu();
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
@@ -268,13 +365,69 @@
<div class:without-rail={!showRail} class="live-page live-controller">
<header class="live-command-header" inert={mobileRailOpen}>
<div class="live-section-context">
<div class="live-context-row">
<div class="live-context-summary">
<span class="live-pulse" aria-hidden="true"></span>
<div>
<small>{data.live.show.title}</small>
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong>
<button
type="button"
class:expanded={expandedContext === 'section'}
class="live-context-title section"
aria-label={`Aktueller Abschnitt: ${currentSectionTitle}`}
aria-expanded={expandedContext === 'section'}
onclick={() => toggleContext('section')}
>
<span>{currentSectionTitle}</span>
</button>
<div class="live-next-inline">
<small>Danach:</small>
<button
type="button"
class:expanded={expandedContext === 'next'}
class="live-context-title next"
aria-label={`Danach: ${nextTitle}`}
aria-expanded={expandedContext === 'next'}
onclick={() => toggleContext('next')}
>
<span>{nextTitle}</span>
</button>
</div>
</div>
<button
bind:this={liveMenuTrigger}
class="live-menu-trigger"
type="button"
aria-haspopup="dialog"
aria-controls="live-menu-popover"
aria-expanded={menuOpen}
onclick={toggleLiveMenu}
>
Menü
</button>
</div>
<div class="live-status-row">
<div class:active={Boolean(presentedUsage)} class="live-output-control">
<div class="live-output-state" aria-live="polite">
<span aria-hidden="true"></span>
<div>
<small>Ausgabe</small>
<strong title={presentedUsage?.title ?? undefined}
>{presentedUsage?.title ?? 'Leer'}</strong
>
</div>
</div>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
</div>
<div
class:warning={hasActiveSectionTimer &&
@@ -294,28 +447,34 @@
</strong>
</div>
<div class:active={Boolean(presentedUsage)} class="live-output-state" aria-live="polite">
<span aria-hidden="true"></span>
<div>
<small>Ausgabe</small>
<strong>{presentedUsage?.title ?? 'Leer'}</strong>
</div>
</div>
<button
bind:this={mobileRunButton}
class="live-mobile-run-button"
class:active={runExpanded}
class="live-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={mobileRailOpen}
onclick={openMobileRail}
aria-expanded={runExpanded}
onclick={toggleRunPanel}
>
Ablauf · Piece {currentPosition || data.live.completedCount}/{data.live.totalCount}
<AppIcon name="list" />
<span>Ablauf · {currentPosition || data.live.completedCount}/{data.live.totalCount}</span>
<AppIcon name="chevron-right" />
</button>
</div>
</header>
<details class="live-menu">
<summary>Menü</summary>
<div class="live-menu-popover">
{#if menuOpen}<div
bind:this={liveMenuPopover}
class="live-menu-popover"
id="live-menu-popover"
role="dialog"
aria-label="Regiemenü"
inert={mobileRailOpen}
>
<section class="live-menu-show">
<small>Sendung</small>
<strong>{data.live.show.title}</strong>
</section>
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
@@ -333,13 +492,6 @@
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<button
type="button"
class="text-button live-desktop-rail-toggle"
onclick={toggleDesktopRail}
>
{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}
</button>
<ThemeToggle />
</section>
<section>
@@ -372,9 +524,7 @@
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>
</details>
</header>
</div>{/if}
<div class="live-messages">
{#if form?.error}<div class="live-message form-message error" role="alert">
@@ -384,7 +534,7 @@
</div>
<div class="live-workspace">
{#if data.live.current}<article
{#if viewedUsage}<article
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
@@ -392,51 +542,43 @@
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<div class="live-kicker">
<span>Piece {currentPosition} von {data.live.totalCount}</span>
{#if showTimes}<strong>
{formatClock(currentActualSeconds)} / {formatClock(
data.live.current.durationSeconds
)}
<span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
<h1 id="current-title">{data.live.current.title}</h1>
</div>
{#if data.live.current.introHtml}<section class="live-script-section live-intro">
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
<div class="prompter-copy">{@html viewedUsage.introHtml}</div>
</section>{/if}
{#if data.live.current.moderationHtml}<section
class="live-script-section moderation-card"
>
{#if viewedUsage.moderationHtml}<section class="live-script-section moderation-card">
<span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
<div class="live-markdown">{@html viewedUsage.moderationHtml}</div>
</section>{/if}
{#if data.live.current.transitionHtml}<section
class="live-script-section transition-card"
>
{#if viewedUsage.transitionHtml}<section class="live-script-section transition-card">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
<div class="live-markdown">{@html viewedUsage.transitionHtml}</div>
</section>{/if}
{#if data.live.current.researchHtml || data.live.current.sources.length || data.live.current.tools.length || data.live.current.ownNotes}
{#if viewedUsage.researchHtml || viewedUsage.sources.length || viewedUsage.tools.length || viewedUsage.ownNotes}
<!-- External source and tool URLs are server-validated private snapshots. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="live-support-actions" aria-label="Private Zusatzinformationen">
{#if data.live.current.researchHtml}<details class="research-details">
{#if viewedUsage.researchHtml}<details class="research-details">
<summary>Recherche</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
<div class="live-markdown">{@html viewedUsage.researchHtml}</div>
</details>{/if}
{#if data.live.current.sources.length}<details class="live-materials">
<summary>Quellen · {data.live.current.sources.length}</summary>
{#if viewedUsage.sources.length}<details class="live-materials">
<summary>Quellen · {viewedUsage.sources.length}</summary>
<ul>
{#each data.live.current.sources as source (source.id)}<li>
{#each viewedUsage.sources as source (source.id)}<li>
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer"
@@ -448,10 +590,10 @@
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.tools.length}<details class="live-materials">
<summary>Werkzeuge · {data.live.current.tools.length}</summary>
{#if viewedUsage.tools.length}<details class="live-materials">
<summary>Werkzeuge · {viewedUsage.tools.length}</summary>
<ul>
{#each data.live.current.tools as tool (tool.id)}<li>
{#each viewedUsage.tools as tool (tool.id)}<li>
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
@@ -464,14 +606,14 @@
</li>{/each}
</ul>
</details>{/if}
{#if data.live.current.ownNotes}<details class="live-materials">
{#if viewedUsage.ownNotes}<details class="live-materials">
<summary>Eigene Notizen</summary>
<p>{data.live.current.ownNotes}</p>
<p>{viewedUsage.ownNotes}</p>
</details>{/if}
</div>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml}
{#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.transitionHtml}
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
{/if}
</div>
@@ -561,26 +703,27 @@
{#each section.usages as usage, usageIndex (usage.id)}
<div
class:current={usage.id === data.live.current?.id}
class:selected={usage.id === viewedUsage?.id}
class:presented={usage.id === data.live.presentationUsageId}
class:done={usage.status === 'done'}
class:skipped={usage.status === 'skipped'}
class="live-run-item"
data-current={usage.id === data.live.current?.id}
>
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button
class="live-run-select"
type="submit"
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
aria-label={`${usage.title} als aktuelles Piece auswählen`}
type="button"
disabled={!data.live.startedAtUtc || usage.id === viewedUsage?.id}
aria-label={`${usage.title} in der Regie auswählen`}
onclick={() => viewUsage(usage.id)}
>
<strong>{usage.title}</strong>
<small>{statusLabel(usage.status)}</small>
<small
>{statusLabel(usage.status)}{usage.id === data.live.presentationUsageId
? ' · Ausgegeben'
: ''}</small
>
</button>
</form>
<div class="live-run-actions">
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
@@ -737,37 +880,20 @@
</div>
<footer class="live-action-bar" inert={mobileRailOpen}>
<div class="live-next-summary">
<span>{data.live.current ? 'Nächstes Piece' : 'Live-Stand'}</span>
<strong>
{data.live.next?.title ??
(sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')}
</strong>
</div>
<div class="live-footer-actions">
{#if data.live.presentationUsageId}<form
class="live-clear-form"
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
{#if data.live.current && viewedUsage}<button
class="secondary-button"
type="button"
disabled={!previousUsage}
onclick={viewPreviousUsage}>Zurück</button
>
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit">Präsentation leeren</button>
</form>{/if}
{#if data.live.current}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value={data.live.current.id} />
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id
{data.live.presentationUsageId === viewedUsage.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
@@ -776,13 +902,27 @@
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit">Überspringen</button>
<button
class="secondary-button"
type="submit"
disabled={!viewingLiveCurrent}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}>Überspringen</button
>
</form>
<form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="primary-button" type="submit">
<button
class="primary-button"
type="submit"
disabled={!viewingLiveCurrent}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}
>
{data.live.next
? 'Erledigen & weiter'
: hasOtherOpenUsages
+30 -4
View File
@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
function berlinLocalToUtc(value: string): string | null {
@@ -34,12 +35,37 @@ function berlinLocalToUtc(value: string): string | null {
return new Date(candidate).toISOString();
}
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() };
export const load: PageServerLoad = ({ locals, url }) => {
const user = requireUser(locals.user);
const today = new Date();
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
previousMonth: shiftMonth(selectedMonth, -1),
nextMonth: shiftMonth(selectedMonth, 1),
todayMonth,
todayDate: berlinDateKey(today)
};
};
export const actions = {
setView: async ({ locals, request }) => {
const user = requireUser(locals.user);
const data = await request.formData();
const view = String(data.get('view') || '');
const todayMonth = berlinMonthKey(new Date());
const month = normalizeMonthKey(String(data.get('month') || ''), todayMonth);
try {
setShowOverviewView(user.id, view);
} catch {
return fail(400, { error: 'Die Ansicht konnte nicht gespeichert werden.' });
}
redirect(303, `/sendungen?month=${encodeURIComponent(month)}`);
},
create: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+164 -22
View File
@@ -1,10 +1,35 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let scheduledShows = $derived(data.shows.filter((show) => show.startsAtUtc !== null));
let undatedShows = $derived(data.shows.filter((show) => show.startsAtUtc === null));
let calendarDays = $derived(buildCalendarDays(data.selectedMonth, data.todayDate));
let showsByDate = $derived.by(() => {
const grouped: Record<string, (typeof data.shows)[number][]> = {};
for (const show of scheduledShows) {
const key = berlinDateKey(show.startsAtUtc!);
const shows = grouped[key] ?? [];
shows.push(show);
grouped[key] = shows;
}
for (const shows of Object.values(grouped)) {
shows.sort((left, right) => left.startsAtUtc!.localeCompare(right.startsAtUtc!));
}
return grouped;
});
let agendaDays = $derived(
calendarDays.filter((day) => day.inSelectedMonth && (showsByDate[day.date]?.length ?? 0) > 0)
);
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit'
});
function berlinDate(value: string | null) {
if (!value) return 'Termin offen';
@@ -14,16 +39,45 @@
timeStyle: 'short'
}).format(new Date(value));
}
function berlinTime(value: string) {
return timeFormatter.format(new Date(value));
}
function agendaDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'UTC',
weekday: 'long',
day: '2-digit',
month: 'long'
}).format(new Date(`${value}T12:00:00Z`));
}
function statusLabel(status: string) {
if (status === 'ready') return 'Bereit';
if (status === 'draft') return 'Entwurf';
if (status === 'live') return 'Live';
return 'Abgeschlossen';
}
</script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page">
<header class="page-heading">
<header class="page-heading shows-heading">
<div>
<h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div>
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
<input type="hidden" name="month" value={data.selectedMonth} />
<button type="submit" name="view" value="list" aria-pressed={data.view === 'list'}>
<AppIcon name="list" /> Liste
</button>
<button type="submit" name="view" value="calendar" aria-pressed={data.view === 'calendar'}>
<AppIcon name="calendar" /> Kalender
</button>
</form>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
@@ -73,23 +127,18 @@
<section class="show-library" aria-labelledby="show-library-title">
<header>
<h2 id="show-library-title">Geplante Sendungen</h2>
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
<span>{data.shows.length}</span>
</header>
{#if data.shows.length}
<div class="show-card-list">
{#each data.shows as show (show.id)}
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}>
<div>
<span class={`show-state ${show.status}`}
>{show.status === 'ready'
? 'Bereit'
: show.status === 'draft'
? 'Entwurf'
: show.status === 'live'
? 'Live'
: 'Abgeschlossen'}</span
{#if data.view === 'list'}
{#if scheduledShows.length}<div class="show-card-list">
{#each scheduledShows as show (show.id)}<a
class="show-card"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<h3>{show.title}</h3>
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
</div>
@@ -99,15 +148,108 @@
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>
{/each}
</div>
</a>{/each}
</div>{:else}<div class="empty-state compact">
<h2>Keine terminierten Sendungen</h2>
<p>Sendungen ohne Termin bleiben unten erreichbar.</p>
</div>{/if}
{:else}
<div class="empty-state">
<span aria-hidden="true"><AppIcon name="plus" /></span>
<h2>Noch keine Sendung</h2>
<p>Oben kann der erste Stream geplant werden.</p>
<div class="show-month-toolbar">
<div>
<p class="eyebrow">Monatsansicht</p>
<h3>{formatMonthLabel(data.selectedMonth)}</h3>
</div>
<!-- Query-only navigation stays on the resolved Sendungen route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<nav aria-label="Kalendermonat" class="show-month-navigation">
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
>
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
>
<a
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
aria-label="Nächster Monat"><AppIcon name="chevron-right" /></a
>
</nav>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
<div class="show-calendar" aria-label={formatMonthLabel(data.selectedMonth)}>
<div class="show-calendar-weekdays" aria-hidden="true">
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as weekday (weekday)}<span
>{weekday}</span
>{/each}
</div>
<div class="show-calendar-grid">
{#each calendarDays as day (day.date)}<section
class:outside={!day.inSelectedMonth}
class:today={day.isToday}
class="show-calendar-day"
aria-label={day.date}
>
<strong>{day.dayNumber}</strong>
{#if day.inSelectedMonth}<div class="show-calendar-events">
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
href={resolve('/sendungen/[id]', { id: show.id })}
class={`show-calendar-event ${show.status}`}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span>{show.title}</span>
<small>{statusLabel(show.status)}</small>
</a>{/each}
</div>{/if}
</section>{/each}
</div>
</div>
<div class="show-agenda" aria-label={`Termine ${formatMonthLabel(data.selectedMonth)}`}>
{#if agendaDays.length}{#each agendaDays as day (day.date)}<section
class:today={day.isToday}
class="show-agenda-day"
>
<h3>{agendaDate(day.date)}</h3>
<div>
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
class="show-agenda-event"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<time>{berlinTime(show.startsAtUtc!)}</time>
<span
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
>
</a>{/each}
</div>
</section>{/each}{:else}<div class="empty-state compact">
<h2>Keine Termine in diesem Monat</h2>
</div>{/if}
</div>
{/if}
</section>
{#if undatedShows.length}<section
class="show-library show-undated"
aria-labelledby="undated-title"
>
<header>
<h2 id="undated-title">Ohne Termin</h2>
<span>{undatedShows.length}</span>
</header>
<div class="show-card-list">
{#each undatedShows as show (show.id)}<a
class="show-card"
href={resolve('/sendungen/[id]', { id: show.id })}
>
<div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
<h3>{show.title}</h3>
<p>Termin offen</p>
</div>
<div class="show-card-facts">
<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
</div>
</a>{/each}
</div>
</section>{/if}
</div>
+188 -19
View File
@@ -196,6 +196,38 @@ function removePresentationFixture() {
database.close();
}
function addCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
const insert = database.prepare(
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text, status,
revision, created_at_utc, updated_at_utc) VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
);
const now = '2026-10-24T10:00:00.000Z';
database.transaction(() => {
insert.run('e2e-calendar-late', 'Kalender Spättermin', '2026-10-24T18:00:00.000Z', now, now);
insert.run('e2e-calendar-early', 'Kalender Frühtermin', '2026-10-24T08:00:00.000Z', now, now);
insert.run(
'e2e-calendar-next',
'Kalender Novembertermin',
'2026-11-03T17:00:00.000Z',
now,
now
);
insert.run('e2e-calendar-undated', 'Kalender ohne Termin', null, now, now);
})();
database.close();
}
function removeCalendarFixture() {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) return;
const database = new Database(path.join(dataDirectory, 'e2e.sqlite'));
database.prepare("DELETE FROM shows WHERE id LIKE 'e2e-calendar-%'").run();
database.close();
}
function setSectionDuration(sectionId: string, seconds: number | null) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.');
@@ -495,6 +527,56 @@ test('sent and planned source filters stay independent and removing the last usa
}
});
test('show overview switches persistently between list, calendar and mobile agenda', async ({
page
}, testInfo) => {
addCalendarFixture();
try {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/sendungen?month=2026-10');
await page.getByRole('button', { name: 'Kalender' }).click();
await expect(page.getByRole('heading', { name: 'Oktober 2026' })).toBeVisible();
const octoberDay = page
.locator('.show-calendar-day')
.filter({ hasText: 'Kalender Frühtermin' });
await expect(octoberDay).toHaveClass(/today/);
await expect(octoberDay).toContainText('Kalender Spättermin');
await expect(octoberDay.locator('.show-calendar-event')).toHaveCount(2);
await expect(octoberDay.locator('.show-calendar-event').first()).toContainText('10:00');
await expect(page.locator('.show-undated')).toContainText('Kalender ohne Termin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-desktop.png'),
fullPage: true
});
await page.getByRole('link', { name: 'Nächster Monat' }).click();
await expect(page.getByRole('heading', { name: 'November 2026' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Kalender Novembertermin' })).toBeVisible();
await page.reload();
await expect(page.getByRole('button', { name: 'Kalender' })).toHaveAttribute(
'aria-pressed',
'true'
);
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('link', { name: 'Vorheriger Monat' }).click();
await expect(page.locator('.show-calendar')).toBeHidden();
await expect(page.locator('.show-agenda')).toContainText('Kalender Frühtermin');
await expect(page.locator('.show-agenda')).toContainText('Kalender Spättermin');
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath('sendungen-kalender-mobile.png'),
fullPage: true
});
await page.getByRole('button', { name: 'Liste' }).click();
await expect(page.getByRole('heading', { name: 'Geplante Sendungen' })).toBeVisible();
} finally {
removeCalendarFixture();
}
});
test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus');
@@ -1083,10 +1165,25 @@ test('live control persists state and exposes only explicit presentation data',
await expect(page.locator('.mobile-navigation')).toHaveCount(0);
await page.getByRole('button', { name: 'Sendung starten' }).click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.getByRole('button', { name: 'Zurück', exact: true })).toBeDisabled();
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
await expect(
page.locator('.live-menu button', { hasText: 'Live-Stand wiederaufnehmen' })
).toHaveCount(0);
const liveMenuTrigger = page.getByRole('button', { name: 'Menü', exact: true });
await liveMenuTrigger.click();
const liveMenu = page.getByRole('dialog', { name: 'Regiemenü' });
await expect(liveMenu).toBeVisible();
await expect(liveMenu).toContainText('Herbstsendung: Kleine Welten');
await expect(liveMenu.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Schrift verkleinern' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await liveMenuTrigger.press('Enter');
await expect(liveMenu).toBeVisible();
await page.locator('.live-current').click({ position: { x: 8, y: 8 } });
await expect(liveMenu).toBeHidden();
await expect(liveMenuTrigger).toBeFocused();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expect(page.locator('.live-output-state')).toContainText('Leer');
await page.getByText('Quellen · 1', { exact: true }).click();
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
await expect(
@@ -1152,7 +1249,7 @@ test('live control persists state and exposes only explicit presentation data',
await page.getByText('Reserve', { exact: true }).click();
await page.getByRole('button', { name: 'Als Nächstes' }).click();
await expect(page.locator('.live-next-summary')).toContainText('Pixelart');
await expect(page.locator('.live-next-inline')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Erledigen & weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click();
@@ -1181,9 +1278,28 @@ test('live control persists state and exposes only explicit presentation data',
await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)
).toBeGreaterThan(0);
const countdownBeforeBack = await page.locator('.section-countdown strong').textContent();
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
await expect(page.locator('#current-title')).toHaveText(
'Pixelart, die nicht nostalgisch sein will'
);
await expect(
page
.locator('.live-run-item.current')
.filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toBeVisible();
await expect(page.getByRole('button', { name: 'Überspringen' })).toBeDisabled();
await expect(page.getByRole('button', { name: /Erledigen/ })).toBeDisabled();
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
const countdownAfterBack = await page.locator('.section-countdown strong').textContent();
expect(countdownAfterBack).not.toBe('05:00');
expect(countdownAfterBack?.slice(0, 3)).toBe(countdownBeforeBack?.slice(0, 3));
await page
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen'
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
@@ -1192,13 +1308,13 @@ test('live control persists state and exposes only explicit presentation data',
.toBe(0);
await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Kommend');
).toContainText('Aktuell');
await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible();
await page
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen'
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(page.locator('#current-title')).toHaveText(
@@ -1267,6 +1383,53 @@ test('live control persists state and exposes only explicit presentation data',
});
await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') });
await page.setViewportSize({ width: 670, height: 450 });
await page.evaluate(() => {
const section = document.querySelector('.live-context-title.section span');
const next = document.querySelector('.live-context-title.next span');
if (section)
section.textContent =
'Aktuelles Segment mit einem außergewöhnlich langen Namen für die kompakte Regie';
if (next)
next.textContent =
'Danach folgt ein Piece mit einem ebenfalls außergewöhnlich langen und vollständigen Titel';
});
const smallHeader = page.locator('.live-command-header');
const smallHeaderBox = await smallHeader.boundingBox();
expect(smallHeaderBox?.height ?? 999).toBeLessThan(105);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'nowrap');
await expect(page.locator('.live-context-title.next span')).toHaveCSS('white-space', 'nowrap');
await page.locator('.live-context-title.section').click();
await expect(page.locator('.live-context-title.section')).toHaveClass(/expanded/);
await expect(page.locator('.live-context-title.section span')).toHaveCSS('white-space', 'normal');
await page.keyboard.press('Escape');
await expect(page.locator('.live-context-title.section')).not.toHaveClass(/expanded/);
await page.locator('.live-context-title.next').focus();
await page.locator('.live-context-title.next').press('Enter');
await expect(page.locator('.live-context-title.next')).toHaveClass(/expanded/);
await page.keyboard.press('Escape');
await liveMenuTrigger.click();
await expect(liveMenu).toBeVisible();
const compactMenuBox = await liveMenu.boundingBox();
expect(compactMenuBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect(compactMenuBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect(
Math.ceil((compactMenuBox?.x ?? 999) + (compactMenuBox?.width ?? 999))
).toBeLessThanOrEqual(670);
expect(
Math.ceil((compactMenuBox?.y ?? 999) + (compactMenuBox?.height ?? 999))
).toBeLessThanOrEqual(450);
await liveMenu.getByRole('link', { name: /Zur Planung/ }).scrollIntoViewIfNeeded();
await expect(liveMenu.getByRole('link', { name: /Zur Planung/ })).toBeVisible();
await liveMenu.getByRole('button', { name: 'Zeiten ausblenden' }).click();
await expect(page.locator('.live-piece-time')).toHaveCount(0);
await liveMenu.getByRole('button', { name: 'Zeiten einblenden' }).click();
await expect(page.locator('.live-piece-time')).toBeVisible();
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await expectNoHorizontalOverflow(page);
await page.screenshot({ path: testInfo.outputPath('live-controller-670x450.png') });
const countdownBeforeReload = await page.locator('.section-countdown strong').textContent();
await page.reload();
await expect(page.locator('#current-title')).toHaveText(
@@ -1287,7 +1450,17 @@ test('live control persists state and exposes only explicit presentation data',
await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight)
).toBe(true);
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') });
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
await liveMenuTrigger.click();
const mobileMenuBox = await liveMenu.boundingBox();
expect(Math.ceil((mobileMenuBox?.x ?? 999) + (mobileMenuBox?.width ?? 999))).toBeLessThanOrEqual(
390
);
expect(Math.ceil((mobileMenuBox?.y ?? 999) + (mobileMenuBox?.height ?? 999))).toBeLessThanOrEqual(
700
);
await page.keyboard.press('Escape');
await expect(liveMenuTrigger).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await expect(liveRail).toHaveClass(/open/);
await expect(railClose).toBeFocused();
await expect
@@ -1302,26 +1475,26 @@ test('live control persists state and exposes only explicit presentation data',
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') });
await page.keyboard.press('Escape');
await expect(liveRail).toBeHidden();
await expect(page.getByRole('button', { name: /Ablauf · Piece/ })).toBeFocused();
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
await expect(page.getByRole('button', { name: /Ablauf ·/ })).toBeFocused();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await page.setViewportSize({ width: 900, height: 450 });
await expect(liveRail).not.toHaveAttribute('role', 'dialog');
await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', '');
await page.setViewportSize({ width: 390, height: 700 });
await expect(liveRail).toBeHidden();
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen'
name: 'Die kleine Konsole mit dem großen Dreh in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail
.getByRole('button', {
name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen'
name: 'Warum praktische Effekte wieder auffallen in der Regie auswählen'
})
.click();
await expect(liveRail).toBeHidden();
@@ -1329,15 +1502,11 @@ test('live control persists state and exposes only explicit presentation data',
'Warum praktische Effekte wieder auffallen'
);
await page.getByRole('button', { name: 'Erledigen' }).click();
await expect(page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toBeVisible();
await page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' }).click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
await expectNoHorizontalOverflow(page);
await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click();
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await output.route('**/state', (route) => route.abort());
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });