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
+1580 -405

No files matched your search

+29 -19
View File
@@ -424,30 +424,40 @@
## Kompakte Live-Regie 0.5.12 ## Kompakte Live-Regie 0.5.12
| Kriterium | Nachweis | Stand | | 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 | | 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 | | 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 | | Niedrige Desktophöhe wird berücksichtigt | 900 × 450 aktiviert kompakte Abstände ohne Transform oder CSS-Zoom | 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 | | 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 |
| 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 | | 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 |
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | 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 |
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | 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 |
| 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 | | Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | 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 | | 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 |
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | 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 |
| Wiederaufnahme nur bei Unterbrechung | Während eines aktiven Pieces fehlt die Aktion; nach unterbrochenem Stand ist sie prominent | erfüllt am 24.10.2026 | | Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | 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 | | 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 ## Ausgeführte Prüfungen 0.5.12
- `npm run lint`: erfolgreich - `npm run lint`: erfolgreich
- `npm run check`: 0 Fehler und 0 Warnungen - `npm run check`: 0 Fehler und 0 Warnungen
- `npm test`: 40 Tests in 7 Dateien erfolgreich - `npm test`: 44 Tests in 8 Dateien erfolgreich
- gezielter Live-Regie-Lauf: 1 von 1 Chromium-Test erfolgreich - gezielte Kalender- und Live-Regie-Läufe: 2 von 2 Chromium-Tests erfolgreich
- `npm run test:e2e`: 16 von 16 Chromium-Browsertests erfolgreich - `npm run test:e2e`: 17 von 17 Chromium-Browsertests erfolgreich
- Screenshots der Live-Regie bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln - Browserprüfungen der Live-Regie bei 1440 × 900, 900 × 450, 670 × 450 und
einschließlich mobilem Ablauf-Sheet visuell kontrolliert 390 × 700 Pixeln sowie des Kalenders bei 1440 × 1000 und 390 × 844 Pixeln
erfolgreich
- `npm run build`: erfolgreich mit `@sveltejs/adapter-node` - `npm run build`: erfolgreich mit `@sveltejs/adapter-node`
- Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft - Versionsgleichheit `0.5.12` in Paket, Lockdatei und Cloudron-Manifest geprüft
- `bash -n start.sh` und `git diff --check`: erfolgreich - `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 Abschnitte mit neuen IDs kopiert; spätere Änderungen an der Sendung können
die Vorlage deshalb nicht verändern. 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 Die Direktanlage aus einer Sendung erzeugt Piece und Verwendung innerhalb einer
verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die verschachtelbaren SQLite-Transaktion. `planning_commands` speichert die
Befehls-ID und das Ergebnis, sodass ein wiederholter Request dieselben IDs 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 Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
Grenze passieren. Grenze passieren.
Normale Auswahl, Fortschaltung oder Wiederaufnahme verändern den Lokale Betrachtungsnavigation, Fortschaltung oder Wiederaufnahme verändern den
Präsentationsstand nicht. Die öffentliche Projektion liest ausschließlich den Präsentationsstand nicht. Zurück und die Auswahl einer Ablaufzeile wechseln nur
gespeicherten Verwendungssnapshot; spätere Änderungen am Original erscheinen den im Browser betrachteten Verwendungssnapshot; tatsächliches Live-Piece,
erst nach einer ausdrücklichen Snapshot-Aktualisierung. 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 Linkmetadaten werden nur auf ausdrückliche Aktion durch
`src/lib/server/presentation.ts` geladen. Jeder HTTP(S)-Abruf löst den Zielhost `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 stehen Fundus und Ablauf nebeneinander. Bis einschließlich `72rem` werden sie
über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale über eine gut erreichbare Umschaltung einzeln gezeigt, damit auch schmale
Desktopfenster ohne horizontalen Überlauf bedienbar bleiben. 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 Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
ohne interne Anwendungsnavigation. 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 ohne Schrift oder Oberfläche zu skalieren. Unterhalb des schmalen Breakpoints
wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung wird ausschließlich die Ablaufspalte als modales Sheet mit Fokusbegrenzung
eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand eingeblendet; beim Wechsel auf einen breiteren Viewport wird dieser Modalzustand
aufgehoben. Dieser Layoutmodus ändert weder die Live-Actions noch die getrennte aufgehoben. Die lokale Betrachtungsnavigation ist keine Live-Action; erst
öffentliche `/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige Präsentieren, Überspringen und Erledigen schreiben den jeweiligen getrennten
Verwendung gebunden und werden nicht zwischen Pieces übertragen. 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 Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren `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 steuert dort unabhängig die Dichte von Abständen und Bedienleisten. Textgröße
und Gesamtoberfläche werden nicht skaliert. und Gesamtoberfläche werden nicht skaliert.
Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl, Der Kopf verwendet genau zwei kompakte Informationszeilen. Lange Kontexttitel
Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte bleiben im Ruhezustand einzeilig und werden auf bewusste Klick- oder
serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein Tastaturaktion lokal vollständig sichtbar. Das Regiemenü ist kein verschachteltes
Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche `details` innerhalb der Glasfläche, sondern ein explizit gesteuertes,
Präsentationsaction verändert die getrennte öffentliche Ausgabe. 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 # Release Notes
## 0.5.12 - Kompakte Live-Regie ## 0.5.12 - Live-Regie und Sendungskalender
Veröffentlicht am 24. Oktober 2026 Veröffentlicht am 24. Oktober 2026
Sendewerk 0.5.12 macht aus der privaten Live-Steuerung eine kompakte Regieansicht 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 für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Die
aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die Sendungsübersicht erhält zusätzlich eine kontobezogen gespeicherte Listen- und
wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche Kalenderansicht. Die öffentliche heinzlive-Ausgabe und die gespeicherten
heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt. Livezustände bleiben technisch getrennt.
### Highlights ### Highlights
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale - Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
App-Seitenleiste, mobilen App-Header oder Bottomnavigation. App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den - Zwei kompakte Kopfzeilen halten Abschnitt, „Danach“, Menü, Ausgabe,
tatsächlich präsentierten Ausgabezustand. 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 - Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar. Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
- Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der - Niedrige Desktopfenster verdichten Abstände und Bedienleisten anhand der
verfügbaren Höhe, ohne Text oder die gesamte Oberfläche zu skalieren. 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 - Die feste Fußleiste ist auf „Zurück“, „Präsentieren“, „Überspringen“ und
„Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende „Erledigen & weiter“ reduziert. Das nächste Piece steht in der Kopfzeile, das
eindeutig benannt. Leeren der Präsentation direkt am Ausgabezustand.
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet, - 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 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. 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. werden nur bei Bedarf geöffnet.
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal - Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
gespeichert. Texte werden nicht automatisch verkleinert. 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 ### Ablauf und Liveverhalten
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und - Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
das aktive Piece bleibt in der Ablaufspalte sichtbar. das aktive Piece bleibt in der Ablaufspalte sichtbar.
- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch - Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags. 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 Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
eine kompakte Aktion am jeweiligen Eintrag erreichbar. eine kompakte Aktion am jeweiligen Eintrag erreichbar.
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte - Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben. Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts. - „Zurück“ und direkte Ablaufwahl verändern weder aktuelles Live-Piece noch
Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche Erledigungsstatus, Timer oder Ausgabe. Bei einem nur betrachteten früheren
Ausgabe. 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 - Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
verwenden unverändert die vorhandenen persistenten Zustände. verwenden unverändert die vorhandenen persistenten Zustände.
- „Live-Stand wiederaufnehmen“ erscheint nur bei einem tatsächlich - „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, - Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar. 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 ### Update und Kompatibilität
- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der - `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
Konfiguration. Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
- Es ist keine Änderung der Konfiguration erforderlich.
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und - Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
Präsentationssnapshots bleiben erhalten. Präsentationssnapshots bleiben erhalten.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`. - Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
### Prüfung ### Prüfung
- 40 Unit- und Verhaltenstests sowie 16 Chromium-E2E-Abläufe sind erfolgreich. - 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
- Die Live-Regie wurde bei 1440 × 900, 900 × 450 und 390 × 700 Pixeln mit - Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
langen Inhalten, unabhängigen Scrollflächen und mobilem Ablauf-Sheet geprüft; mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
die erzeugten Screenshots wurden visuell kontrolliert. 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 - Reales OBS, echte YouTube-/TikTok-Wiedergabe und Safari auf einem iOS-Gerät
standen nicht zur Verfügung. 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. Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
Etappe 6 wurde nicht begonnen. 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 - fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und - zweizeilige feste Kopfzeile: Abschnitt und „Danach“ teilen sich die erste
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece, Zeile mit dem Menü; Ausgabe, Abschnittscountdown und Ablaufschalter bilden die
Präsentation, Überspringen und kontextgerechtem Erledigen 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 - aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen 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 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 - 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 als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter - kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, lokaler
Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und Betrachtung jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
erreichbar, dauerhafte Sortierpfeile entfallen erreichbar, dauerhafte Sortierpfeile entfallen
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece - die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
beginnt oben statt an der Position des vorherigen Textes 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; - Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
leere Moderationsbereiche werden nicht dargestellt leere Moderationsbereiche werden nicht dargestellt
- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation - Zurück und direkte Ablaufwahl verändern weder tatsächliches Live-Piece noch
verwenden unverändert ihre getrennten persistenten Zustände 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 - die Wiederaufnahmeaktion erscheint nur bei tatsächlich unterbrochenem
Live-Stand statt während eines laufenden Pieces Live-Stand statt während eines laufenden Pieces
Etappe 6 bleibt unverändert unbegonnen. 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 Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
bleiben mangels verfügbarer Umgebung offen. 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; display: grid;
position: relative; position: relative;
z-index: 60; z-index: 60;
grid-template-columns: minmax(12rem, 1fr) auto minmax(8rem, 0.55fr) auto; gap: 0.4rem;
align-items: center;
gap: 0.65rem;
min-width: 0; 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: 1px solid color-mix(in srgb, var(--gold) 24%, var(--line));
border-radius: 0.9rem; border-radius: 0.9rem;
background: var(--glass); background: var(--glass);
@@ -1014,22 +1012,41 @@ textarea:focus {
backdrop-filter: blur(20px) saturate(1.08); backdrop-filter: blur(20px) saturate(1.08);
} }
.live-section-context, .live-context-row,
.live-output-state { .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; display: flex;
min-width: 0; min-width: 0;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
} }
.live-section-context > div,
.live-output-state > div { .live-output-state > div {
display: grid; display: grid;
min-width: 0; min-width: 0;
} }
.live-section-context small,
.live-output-state small, .live-output-state small,
.live-next-inline small,
.section-countdown span { .section-countdown span {
color: var(--text-faint); color: var(--text-faint);
font-size: 0.59rem; font-size: 0.59rem;
@@ -1038,7 +1055,6 @@ textarea:focus {
text-transform: uppercase; text-transform: uppercase;
} }
.live-section-context strong,
.live-output-state strong { .live-output-state strong {
overflow: hidden; overflow: hidden;
font-size: 0.75rem; font-size: 0.75rem;
@@ -1046,6 +1062,67 @@ textarea:focus {
white-space: nowrap; 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 { .live-pulse {
width: 0.48rem; width: 0.48rem;
height: 0.48rem; height: 0.48rem;
@@ -1089,23 +1166,69 @@ textarea:focus {
background: var(--text-faint); background: var(--text-faint);
} }
.live-output-state.active > span { .live-output-control.active .live-output-state > span {
background: var(--accent); background: var(--accent);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent); box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 12%, transparent);
} }
.live-mobile-run-button { .live-output-control {
display: none; display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
} }
.live-menu { .live-output-control .live-output-state {
position: relative; min-width: 0;
z-index: 40;
width: fit-content;
justify-self: end;
} }
.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; width: fit-content;
padding: 0.48rem 0.65rem; padding: 0.48rem 0.65rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -1114,17 +1237,27 @@ textarea:focus {
cursor: pointer; cursor: pointer;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 750; 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 { .live-menu-popover {
position: absolute; position: fixed;
z-index: 50; z-index: 200;
top: calc(100% + 0.4rem); top: max(3.5rem, calc(env(safe-area-inset-top) + 3rem));
right: 0; right: max(0.65rem, env(safe-area-inset-right));
display: grid; display: grid;
width: min(22rem, calc(100vw - 1.3rem)); 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; overflow-y: auto;
padding: 0.7rem; padding: 0.7rem;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
@@ -1132,6 +1265,29 @@ textarea:focus {
background: var(--glass-strong); background: var(--glass-strong);
box-shadow: var(--shadow-large); box-shadow: var(--shadow-large);
backdrop-filter: blur(24px); 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 { .live-menu-popover section {
@@ -1231,39 +1387,42 @@ textarea:focus {
} }
.live-piece-heading { .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 { .live-piece-position {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
color: var(--accent); color: var(--accent);
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 750; font-weight: 750;
letter-spacing: 0.055em; letter-spacing: 0.055em;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap;
} }
.live-kicker strong { .live-piece-time {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.live-current h1 { .live-current h1 {
max-width: 30ch; max-width: none;
margin: 0.45rem 0 0; margin: 0;
font-size: clamp(1.45rem, 2.7vw, 2.55rem); font-size: clamp(1.2rem, 2.2vw, 1.9rem);
font-weight: 690; font-weight: 690;
letter-spacing: -0.045em; letter-spacing: -0.035em;
line-height: 1.04; line-height: 1.08;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.live-script-section { .live-script-section {
max-width: 58rem; max-width: 58rem;
padding: 1rem 0; padding: 0.75rem 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
@@ -1510,6 +1669,14 @@ textarea:focus {
background: color-mix(in srgb, var(--gold) 10%, var(--panel)); 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 { .live-run-item.done {
opacity: 0.68; opacity: 0.68;
} }
@@ -1519,7 +1686,7 @@ textarea:focus {
opacity: 0.62; opacity: 0.62;
} }
.live-run-item > form:first-child { .live-run-item > .live-run-select {
min-width: 0; min-width: 0;
} }
@@ -1602,6 +1769,15 @@ textarea:focus {
opacity: 1; 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-run-select strong,
.live-reserve-item strong { .live-reserve-item strong {
font-size: 0.67rem; font-size: 0.67rem;
@@ -1673,9 +1849,8 @@ textarea:focus {
display: flex; display: flex;
min-width: 0; min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 0.8rem; padding: 0.4rem 0.55rem;
padding: 0.55rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line)); border: 1px solid color-mix(in srgb, var(--gold) 28%, var(--line));
border-radius: 0.9rem; border-radius: 0.9rem;
background: var(--glass-strong); background: var(--glass-strong);
@@ -1683,30 +1858,12 @@ textarea:focus {
backdrop-filter: blur(22px) saturate(1.08); 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 { .live-footer-actions {
display: flex; display: grid;
width: min(100%, 42rem);
align-items: center; align-items: center;
justify-content: flex-end; grid-template-columns: repeat(4, minmax(0, auto));
justify-content: end;
gap: 0.4rem; gap: 0.4rem;
} }
@@ -1715,6 +1872,7 @@ textarea:focus {
} }
.live-footer-actions button { .live-footer-actions button {
min-height: 2.2rem;
white-space: nowrap; white-space: nowrap;
} }
@@ -1748,7 +1906,7 @@ textarea:focus {
} }
.live-piece-heading { .live-piece-heading {
padding-bottom: 0.55rem; padding-bottom: 0.4rem;
} }
.live-script-section { .live-script-section {
@@ -1764,50 +1922,54 @@ textarea:focus {
} }
.live-command-header { .live-command-header {
grid-template-areas: gap: 0.2rem;
'context menu' padding: 0.32rem 0.4rem;
'countdown output' }
'run run';
.live-context-row {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 0.4rem; gap: 0.4rem;
padding: 0.45rem;
} }
.live-section-context { .live-context-summary {
grid-area: context; gap: 0.25rem;
} }
.section-countdown { .live-context-title {
grid-area: countdown; max-width: 34vw;
min-width: 0; padding-inline: 0.25rem;
text-align: left;
} }
.live-output-state { .live-next-inline {
grid-area: output; gap: 0.12rem;
min-width: 0; 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; padding-inline: 0.35rem;
} }
.live-menu { .section-countdown {
grid-area: menu; min-width: 5.5rem;
padding: 0.28rem 0.4rem;
} }
.live-mobile-run-button { .live-run-button {
display: block; padding-inline: 0.45rem;
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-workspace, .live-workspace,
@@ -1855,7 +2017,11 @@ textarea:focus {
} }
.live-current h1 { .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 { .live-support-actions {
@@ -1869,18 +2035,13 @@ textarea:focus {
} }
.live-action-bar { .live-action-bar {
display: grid; padding: 0.35rem;
gap: 0.4rem;
padding: 0.45rem;
}
.live-next-summary strong {
font-size: 0.68rem;
} }
.live-footer-actions { .live-footer-actions {
display: grid; width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
justify-content: stretch;
gap: 0.3rem; gap: 0.3rem;
} }
@@ -1897,16 +2058,41 @@ textarea:focus {
font-size: 0.64rem; font-size: 0.64rem;
} }
.live-clear-form {
grid-column: 1 / -1;
}
.live-finished-label { .live-finished-label {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; 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 { .heinzlive-output {
min-height: 100vh; min-height: 100vh;
padding: clamp(2rem, 6vw, 6rem); padding: clamp(2rem, 6vw, 6rem);
@@ -3941,6 +4127,42 @@ textarea:focus {
max-width: 78rem; 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-create-panel,
.show-library, .show-library,
.show-settings { .show-settings {
@@ -4157,6 +4379,237 @@ textarea:focus {
font-size: 0.78rem; 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 { .show-settings {
margin-bottom: 0.75rem; 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', () => { 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 () => { it('creates the first Barkeeper once and stores no plaintext secret', async () => {
const { createFirstAdmin } = await import('./auth.js'); const { createFirstAdmin } = await import('./auth.js');
const password = 'Ein-sehr-langes-Testpasswort'; const password = 'Ein-sehr-langes-Testpasswort';
+18
View File
@@ -26,6 +26,7 @@ const PASSWORD_OPTIONS = {
} as const; } as const;
export type Role = 'barkeeper' | 'thekencrew'; export type Role = 'barkeeper' | 'thekencrew';
export type ShowOverviewView = 'list' | 'calendar';
export type AuthUser = { export type AuthUser = {
id: string; id: string;
email: string; email: string;
@@ -66,6 +67,23 @@ export function normalizeEmail(email: string): string {
return email.trim().toLowerCase(); 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 { export function validEmail(email: string): boolean {
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
} }
+1 -1
View File
@@ -62,7 +62,7 @@ describe('database setup', () => {
database.close(); database.close();
expect(counts).toEqual({ expect(counts).toEqual({
migrations: 11, migrations: 12,
pieces: 6, pieces: 6,
shows: 2, shows: 2,
usages: 5, usages: 5,
+319 -179
View File
@@ -11,23 +11,38 @@
let promptSize = $state(1.55); let promptSize = $state(1.55);
let showRail = $state(true); let showRail = $state(true);
let mobileRailOpen = $state(false); let mobileRailOpen = $state(false);
let isMobile = $state(false);
let showTimes = $state(true); let showTimes = $state(true);
let menuOpen = $state(false);
let expandedContext = $state<'section' | 'next' | null>(null);
let popupMessage = $state(''); let popupMessage = $state('');
let now = $state(Date.now()); let now = $state(Date.now());
let loadedAt = $state(Date.now()); let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null)); let currentId = $state(untrack(() => data.live.current?.id ?? null));
let loadedRevision = $state(untrack(() => data.live.liveRevision)); let loadedRevision = $state(untrack(() => data.live.liveRevision));
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
let pieceScroller = $state<HTMLElement>(); let pieceScroller = $state<HTMLElement>();
let railScroller = $state<HTMLElement>(); let railScroller = $state<HTMLElement>();
let liveRail = $state<HTMLElement>(); let liveRail = $state<HTMLElement>();
let mobileRunButton = $state<HTMLButtonElement>(); let mobileRunButton = $state<HTMLButtonElement>();
let railCloseButton = $state<HTMLButtonElement>(); let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>();
let scrollPositions: Record<string, number> = {}; let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null; let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages)); 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( let currentPosition = $derived(
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0 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( let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
); );
@@ -51,10 +66,19 @@
data.live.completedCount === data.live.totalCount 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( let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 0) + (data.live.current?.actualDurationSeconds ?? 0) +
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0) (data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
); );
let viewedActualSeconds = $derived(
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
);
let sectionElapsedSeconds = $derived( let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) + (data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc (data.live.sectionTimer?.runningSinceUtc
@@ -80,6 +104,7 @@
loadedRevision = data.live.liveRevision; loadedRevision = data.live.liveRevision;
loadedAt = Date.now(); loadedAt = Date.now();
if (changedPiece) { if (changedPiece) {
viewedUsageId = nextCurrentId;
void tick().then(() => { void tick().then(() => {
if (pieceScroller) if (pieceScroller)
pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0; pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0;
@@ -121,19 +146,36 @@
localStorage.removeItem(key); localStorage.removeItem(key);
} }
const mobileBreakpoint = matchMedia('(max-width: 46rem)'); const mobileBreakpoint = matchMedia('(max-width: 46rem)');
isMobile = mobileBreakpoint.matches;
const handleBreakpointChange = (event: MediaQueryListEvent) => { const handleBreakpointChange = (event: MediaQueryListEvent) => {
isMobile = event.matches;
if (!event.matches) void closeMobileRail(false); 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); mobileBreakpoint.addEventListener('change', handleBreakpointChange);
document.addEventListener('click', handleDocumentClick);
const interval = setInterval(() => (now = Date.now()), 1000); const interval = setInterval(() => (now = Date.now()), 1000);
return () => { return () => {
mobileBreakpoint.removeEventListener('change', handleBreakpointChange); mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
document.removeEventListener('click', handleDocumentClick);
clearInterval(interval); clearInterval(interval);
}; };
}); });
function persistSettings() { function persistSettings() {
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop; if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
localStorage.setItem( localStorage.setItem(
`sendewerk-live-${data.live.show.id}`, `sendewerk-live-${data.live.show.id}`,
JSON.stringify({ JSON.stringify({
@@ -155,7 +197,31 @@
persistSettings(); 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() { async function openMobileRail() {
await closeLiveMenu(false);
mobileRailOpen = true; mobileRailOpen = true;
await tick(); await tick();
railCloseButton?.focus(); railCloseButton?.focus();
@@ -169,12 +235,43 @@
} }
function handleWindowKeydown(event: KeyboardEvent) { 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(); event.preventDefault();
void closeMobileRail(); 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) { function trapRailFocus(event: KeyboardEvent) {
if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return; if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return;
const focusable = Array.from( const focusable = Array.from(
@@ -220,7 +317,7 @@
} }
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => { const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open'); void closeLiveMenu();
const submittedFromRail = Boolean(formElement.closest('.live-rail')); const submittedFromRail = Boolean(formElement.closest('.live-rail'));
const keepRailOpen = formElement.dataset.keepRailOpen === 'true'; const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
const command = formElement.elements.namedItem('commandId'); const command = formElement.elements.namedItem('commandId');
@@ -238,8 +335,8 @@
}; };
}; };
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => { const enhanceOutput: SubmitFunction = ({ submitter }) => {
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open'); void closeLiveMenu();
popupMessage = ''; popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output'); outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.'; if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
@@ -268,114 +365,167 @@
<div class:without-rail={!showRail} class="live-page live-controller"> <div class:without-rail={!showRail} class="live-page live-controller">
<header class="live-command-header" inert={mobileRailOpen}> <header class="live-command-header" inert={mobileRailOpen}>
<div class="live-section-context"> <div class="live-context-row">
<span class="live-pulse" aria-hidden="true"></span> <div class="live-context-summary">
<div> <span class="live-pulse" aria-hidden="true"></span>
<small>{data.live.show.title}</small> <button
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong> type="button"
</div> class:expanded={expandedContext === 'section'}
</div> class="live-context-title section"
aria-label={`Aktueller Abschnitt: ${currentSectionTitle}`}
<div aria-expanded={expandedContext === 'section'}
class:warning={hasActiveSectionTimer && onclick={() => toggleContext('section')}
sectionRemainingSeconds <= 120 && >
sectionRemainingSeconds >= 0} <span>{currentSectionTitle}</span>
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0} </button>
class="section-countdown" <div class="live-next-inline">
aria-live="off" <small>Danach:</small>
>
<span>Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</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"
type="button"
aria-controls="live-run-sheet"
aria-expanded={mobileRailOpen}
onclick={openMobileRail}
>
Ablauf · Piece {currentPosition || data.live.completedCount}/{data.live.totalCount}
</button>
<details class="live-menu">
<summary>Menü</summary>
<div class="live-menu-popover">
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<button <button
type="button" type="button"
class="text-button live-desktop-rail-toggle" class:expanded={expandedContext === 'next'}
onclick={toggleDesktopRail} class="live-context-title next"
aria-label={`Danach: ${nextTitle}`}
aria-expanded={expandedContext === 'next'}
onclick={() => toggleContext('next')}
> >
{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'} <span>{nextTitle}</span>
</button> </button>
<ThemeToggle /> </div>
</section>
<section>
<strong>Ausgabekanal</strong>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
method="POST"
action={liveAction('start')}
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="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div> </div>
</details> <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 &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span>Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
<button
bind:this={mobileRunButton}
class:active={runExpanded}
class="live-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={runExpanded}
onclick={toggleRunPanel}
>
<AppIcon name="list" />
<span>Ablauf · {currentPosition || data.live.completedCount}/{data.live.totalCount}</span>
<AppIcon name="chevron-right" />
</button>
</div>
</header> </header>
{#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">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
method="POST"
action={liveAction('start')}
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="text-button" type="submit">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>{/if}
<div class="live-messages"> <div class="live-messages">
{#if form?.error}<div class="live-message form-message error" role="alert"> {#if form?.error}<div class="live-message form-message error" role="alert">
{form.error} {form.error}
@@ -384,7 +534,7 @@
</div> </div>
<div class="live-workspace"> <div class="live-workspace">
{#if data.live.current}<article {#if viewedUsage}<article
class="live-current" class="live-current"
style={`--prompt-size: ${promptSize}rem`} style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title" aria-labelledby="current-title"
@@ -392,51 +542,43 @@
> >
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}> <div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading"> <div class="live-piece-heading">
<div class="live-kicker"> <span class="live-piece-position">Piece {viewedPosition}/{data.live.totalCount}</span>
<span>Piece {currentPosition} von {data.live.totalCount}</span> <h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong> {#if showTimes}<strong class="live-piece-time">
{formatClock(currentActualSeconds)} / {formatClock( {formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
data.live.current.durationSeconds </strong>{/if}
)}
</strong>{/if}
</div>
<h1 id="current-title">{data.live.current.title}</h1>
</div> </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> <span>Einleitung</span>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. --> <!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- 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} </section>{/if}
{#if data.live.current.moderationHtml}<section {#if viewedUsage.moderationHtml}<section class="live-script-section moderation-card">
class="live-script-section moderation-card"
>
<span>Moderationsstichpunkte</span> <span>Moderationsstichpunkte</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- 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} </section>{/if}
{#if data.live.current.transitionHtml}<section {#if viewedUsage.transitionHtml}<section class="live-script-section transition-card">
class="live-script-section transition-card"
>
<span>Überleitung</span> <span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- 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} </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. --> <!-- External source and tool URLs are server-validated private snapshots. -->
<!-- eslint-disable svelte/no-navigation-without-resolve --> <!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="live-support-actions" aria-label="Private Zusatzinformationen"> <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> <summary>Recherche</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- 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} </details>{/if}
{#if data.live.current.sources.length}<details class="live-materials"> {#if viewedUsage.sources.length}<details class="live-materials">
<summary>Quellen · {data.live.current.sources.length}</summary> <summary>Quellen · {viewedUsage.sources.length}</summary>
<ul> <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. --> <!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer" <a href={source.url} target="_blank" rel="noopener noreferrer"
@@ -448,10 +590,10 @@
</li>{/each} </li>{/each}
</ul> </ul>
</details>{/if} </details>{/if}
{#if data.live.current.tools.length}<details class="live-materials"> {#if viewedUsage.tools.length}<details class="live-materials">
<summary>Werkzeuge · {data.live.current.tools.length}</summary> <summary>Werkzeuge · {viewedUsage.tools.length}</summary>
<ul> <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. --> {#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a <a
@@ -464,14 +606,14 @@
</li>{/each} </li>{/each}
</ul> </ul>
</details>{/if} </details>{/if}
{#if data.live.current.ownNotes}<details class="live-materials"> {#if viewedUsage.ownNotes}<details class="live-materials">
<summary>Eigene Notizen</summary> <summary>Eigene Notizen</summary>
<p>{data.live.current.ownNotes}</p> <p>{viewedUsage.ownNotes}</p>
</details>{/if} </details>{/if}
</div> </div>
<!-- eslint-enable svelte/no-navigation-without-resolve --> <!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if} {/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> <p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
{/if} {/if}
</div> </div>
@@ -561,26 +703,27 @@
{#each section.usages as usage, usageIndex (usage.id)} {#each section.usages as usage, usageIndex (usage.id)}
<div <div
class:current={usage.id === data.live.current?.id} 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:done={usage.status === 'done'}
class:skipped={usage.status === 'skipped'} class:skipped={usage.status === 'skipped'}
class="live-run-item" class="live-run-item"
data-current={usage.id === data.live.current?.id} data-current={usage.id === data.live.current?.id}
> >
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}> <button
<input type="hidden" name="showId" value={data.live.show.id} /> class="live-run-select"
<input type="hidden" name="usageId" value={usage.id} /> type="button"
<input type="hidden" name="revision" value={data.live.liveRevision} /> disabled={!data.live.startedAtUtc || usage.id === viewedUsage?.id}
<input type="hidden" name="commandId" value={data.commandId} /> aria-label={`${usage.title} in der Regie auswählen`}
<button onclick={() => viewUsage(usage.id)}
class="live-run-select" >
type="submit" <strong>{usage.title}</strong>
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id} <small
aria-label={`${usage.title} als aktuelles Piece auswählen`} >{statusLabel(usage.status)}{usage.id === data.live.presentationUsageId
? ' · Ausgegeben'
: ''}</small
> >
<strong>{usage.title}</strong> </button>
<small>{statusLabel(usage.status)}</small>
</button>
</form>
<div class="live-run-actions"> <div class="live-run-actions">
{#if usage.status === 'open'}<details class="live-run-order"> {#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`} <summary aria-label={`${usage.title} sortieren`}
@@ -737,37 +880,20 @@
</div> </div>
<footer class="live-action-bar" inert={mobileRailOpen}> <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"> <div class="live-footer-actions">
{#if data.live.presentationUsageId}<form {#if data.live.current && viewedUsage}<button
class="live-clear-form" class="secondary-button"
method="POST" type="button"
action={liveAction('present')} disabled={!previousUsage}
use:enhance={enhanceCommand} 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="showId" value={data.live.show.id} />
<input type="hidden" name="usageId" value="" /> <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="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="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"> <button class="secondary-button" type="submit">
{data.live.presentationUsageId === data.live.current.id {data.live.presentationUsageId === viewedUsage.id
? 'Erneut präsentieren' ? 'Erneut präsentieren'
: 'Präsentieren'} : 'Präsentieren'}
</button> </button>
@@ -776,13 +902,27 @@
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <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>
<form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}> <form method="POST" action={liveAction('complete')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} /> <input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} /> <input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} /> <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 {data.live.next
? 'Erledigen & weiter' ? 'Erledigen & weiter'
: hasOtherOpenUsages : hasOtherOpenUsages
+30 -4
View File
@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; 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'; import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
function berlinLocalToUtc(value: string): string | null { function berlinLocalToUtc(value: string): string | null {
@@ -34,12 +35,37 @@ function berlinLocalToUtc(value: string): string | null {
return new Date(candidate).toISOString(); return new Date(candidate).toISOString();
} }
export const load: PageServerLoad = ({ locals }) => { export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals.user); const user = requireUser(locals.user);
return { shows: listShows(), templates: listShowTemplates() }; 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 = { 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 }) => { create: async ({ locals, request }) => {
requireUser(locals.user); requireUser(locals.user);
const data = await request.formData(); const data = await request.formData();
+174 -32
View File
@@ -1,10 +1,35 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
import { formatDuration } from '#lib/format.js'; import { formatDuration } from '#lib/format.js';
import AppIcon from '#lib/components/AppIcon.svelte'; import AppIcon from '#lib/components/AppIcon.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data, form }: PageProps = $props(); 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) { function berlinDate(value: string | null) {
if (!value) return 'Termin offen'; if (!value) return 'Termin offen';
@@ -14,16 +39,45 @@
timeStyle: 'short' timeStyle: 'short'
}).format(new Date(value)); }).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> </script>
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head> <svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
<div class="page-wrap shows-page"> <div class="page-wrap shows-page">
<header class="page-heading"> <header class="page-heading shows-heading">
<div> <div>
<h1>Sendungen</h1> <h1>Sendungen</h1>
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p> <p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
</div> </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> </header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if} {#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
@@ -73,41 +127,129 @@
<section class="show-library" aria-labelledby="show-library-title"> <section class="show-library" aria-labelledby="show-library-title">
<header> <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> <span>{data.shows.length}</span>
</header> </header>
{#if data.shows.length}
<div class="show-card-list"> {#if data.view === 'list'}
{#each data.shows as show (show.id)} {#if scheduledShows.length}<div class="show-card-list">
<a class="show-card" href={resolve('/sendungen/[id]', { id: show.id })}> {#each scheduledShows as show (show.id)}<a
<div> class="show-card"
<span class={`show-state ${show.status}`} href={resolve('/sendungen/[id]', { id: show.id })}
>{show.status === 'ready' >
? 'Bereit' <div>
: show.status === 'draft' <span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
? 'Entwurf' <h3>{show.title}</h3>
: show.status === 'live' <p>{berlinDate(show.startsAtUtc)} Uhr</p>
? 'Live' </div>
: 'Abgeschlossen'}</span <div class="show-card-facts">
> {#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
<h3>{show.title}</h3> >{/if}{#if show.totalDurationSeconds}<span
<p>{berlinDate(show.startsAtUtc)} Uhr</p> ><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
</div> >{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
<div class="show-card-facts"> </div>
{#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span </a>{/each}
>{/if}{#if show.totalDurationSeconds}<span </div>{:else}<div class="empty-state compact">
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span <h2>Keine terminierten Sendungen</h2>
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b> <p>Sendungen ohne Termin bleiben unten erreichbar.</p>
</div> </div>{/if}
</a>
{/each}
</div>
{:else} {:else}
<div class="empty-state"> <div class="show-month-toolbar">
<span aria-hidden="true"><AppIcon name="plus" /></span> <div>
<h2>Noch keine Sendung</h2> <p class="eyebrow">Monatsansicht</p>
<p>Oben kann der erste Stream geplant werden.</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> </div>
{/if} {/if}
</section> </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> </div>
+188 -19
View File
@@ -196,6 +196,38 @@ function removePresentationFixture() {
database.close(); 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) { function setSectionDuration(sectionId: string, seconds: number | null) {
const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR; const dataDirectory = process.env.SENDEWERK_E2E_DATA_DIR;
if (!dataDirectory) throw new Error('E2E-Datenverzeichnis ist nicht konfiguriert.'); 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) => { test('desktop builder keeps the fundus beside the wider run sheet', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/fundus'); 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 expect(page.locator('.mobile-navigation')).toHaveCount(0);
await page.getByRole('button', { name: 'Sendung starten' }).click(); 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.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('.section-countdown')).toContainText(/0[45]:\d{2}/);
await expect( const liveMenuTrigger = page.getByRole('button', { name: 'Menü', exact: true });
page.locator('.live-menu button', { hasText: 'Live-Stand wiederaufnehmen' }) await liveMenuTrigger.click();
).toHaveCount(0); 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 page.getByText('Quellen · 1', { exact: true }).click();
await expect(page.locator('.live-materials')).toContainText('Herstellerseite'); await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
await expect( 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.getByText('Reserve', { exact: true }).click();
await page.getByRole('button', { name: 'Als Nächstes' }).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 page.getByRole('button', { name: 'Erledigen & weiter' }).click();
await expect(page.locator('.live-current')).toContainText('Pixelart'); await expect(page.locator('.live-current')).toContainText('Pixelart');
await page.getByRole('button', { name: 'Überspringen' }).click(); 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) await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)
).toBeGreaterThan(0); ).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 await page
.getByRole('button', { .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(); .click();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); 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); .toBe(0);
await expect( await expect(
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' }) page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
).toContainText('Kommend'); ).toContainText('Aktuell');
await expect( await expect(
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' }) output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
).toBeVisible(); ).toBeVisible();
await page await page
.getByRole('button', { .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(); .click();
await expect(page.locator('#current-title')).toHaveText( 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.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(); const countdownBeforeReload = await page.locator('.section-countdown strong').textContent();
await page.reload(); await page.reload();
await expect(page.locator('#current-title')).toHaveText( 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) await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight)
).toBe(true); ).toBe(true);
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') }); 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(liveRail).toHaveClass(/open/);
await expect(railClose).toBeFocused(); await expect(railClose).toBeFocused();
await expect 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.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') });
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(liveRail).toBeHidden(); await expect(liveRail).toBeHidden();
await expect(page.getByRole('button', { name: /Ablauf · Piece/ })).toBeFocused(); await expect(page.getByRole('button', { name: /Ablauf ·/ })).toBeFocused();
await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); await page.getByRole('button', { name: /Ablauf ·/ }).click();
await page.setViewportSize({ width: 900, height: 450 }); await page.setViewportSize({ width: 900, height: 450 });
await expect(liveRail).not.toHaveAttribute('role', 'dialog'); await expect(liveRail).not.toHaveAttribute('role', 'dialog');
await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', ''); await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', '');
await page.setViewportSize({ width: 390, height: 700 }); await page.setViewportSize({ width: 390, height: 700 });
await expect(liveRail).toBeHidden(); await expect(liveRail).toBeHidden();
await page.getByRole('button', { name: /Ablauf · Piece/ }).click(); await page.getByRole('button', { name: /Ablauf ·/ }).click();
await liveRail await liveRail
.getByRole('button', { .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(); .click();
await expect(liveRail).toBeHidden(); await expect(liveRail).toBeHidden();
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh'); 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 await liveRail
.getByRole('button', { .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(); .click();
await expect(liveRail).toBeHidden(); await expect(liveRail).toBeHidden();
@@ -1329,15 +1502,11 @@ test('live control persists state and exposes only explicit presentation data',
'Warum praktische Effekte wieder auffallen' '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 page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click();
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen'); await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
await output.route('**/state', (route) => route.abort()); await output.route('**/state', (route) => route.abort());
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 }); await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });